mirror of
https://github.com/redis/go-redis.git
synced 2026-10-22 19:49:00 +03:00
feat(timeseries): add TS.QUERYLABELS support (#3926)
* feat(timeseries): add TS.QUERYLABELS support (Redis 8.10)
- TSQueryLabels lists label names, TSQueryLabelValues the values of
one label, across series matching optional TS.QUERYINDEX-style
filters
- the FILTER token is emitted only when expressions are given (the
server rejects a bare FILTER); expressions pass through verbatim
and the VALUES label is matched byte-exactly
- replies are unordered, server-deduplicated string sets under both
RESP2 and RESP3; empty replies are success, not errors
- wire-format unit tests plus a RESP2/RESP3 integration test that
gates on the live server supporting the command
- runnable example under example/ts-querylabels
* fix(tests): use skip for tests
* test(timeseries): version-gate TS.QUERYLABELS
Replace the standalone probe test (command probing plus the
REDIS_QUERYLABELS_TEST_ADDR env var) with Ginkgo specs in the main
suite, gated on SkipBeforeRedisVersion("8.10"). The plain go test also
called the Ginkgo-based SkipBeforeRedisVersion helper, which panics
outside the suite when it actually skips; moving the specs into the
suite fixes that. Covers both command forms under RESP2 and RESP3.
* chore(deps): bump grpc to v1.82.1 in otel examples
govulncheck fails every PR on GO-2026-6061 in google.golang.org/grpc
v1.80.0 (indirect via the OTLP exporters); v1.82.1 carries the fix.
---------
Co-authored-by: ofekshenawa <104765379+ofekshenawa@users.noreply.github.com>
This commit is contained in:
43
example/ts-querylabels/README.md
Normal file
43
example/ts-querylabels/README.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# TS.QUERYLABELS example
|
||||
|
||||
This example demonstrates `TSQueryLabels` and `TSQueryLabelValues`, the
|
||||
client bindings for the two forms of `TS.QUERYLABELS` (Redis 8.10+):
|
||||
|
||||
- `TSQueryLabels(ctx, filters)` — the set of label names present on the
|
||||
matching time series (`TS.QUERYLABELS LABELS [FILTER ...]`).
|
||||
- `TSQueryLabelValues(ctx, label, filters)` — the set of values assigned to
|
||||
one label across the matching series
|
||||
(`TS.QUERYLABELS VALUES label [FILTER ...]`).
|
||||
|
||||
Together with the existing `TSQueryIndex` they form the drill-down flow
|
||||
dashboarding tools such as Grafana use to populate variable dropdowns:
|
||||
label names → values of the chosen label → matching series keys.
|
||||
|
||||
Key points shown by the example:
|
||||
|
||||
- Filters use the same expression language as `TSQueryIndex`/`TSMRange` and
|
||||
are passed to the server verbatim; passing no filters queries all indexed
|
||||
series.
|
||||
- Replies are unordered, deduplicated sets of strings; the `LABELS` form
|
||||
includes the label names used in the filter itself.
|
||||
- A label carried by no matching series yields an empty reply, not an error.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Redis 8.10+ with the TimeSeries module, listening on `localhost:6379`.
|
||||
|
||||
## Run
|
||||
|
||||
```shell
|
||||
go run .
|
||||
```
|
||||
|
||||
Expected output (order within the collections may differ):
|
||||
|
||||
```
|
||||
label names for type=sensor: [location type unit]
|
||||
locations for type=sensor: [bedroom kitchen]
|
||||
kitchen sensor series: [ts:hum:kitchen ts:temp:kitchen]
|
||||
values of an absent label: 0
|
||||
label names, all series: [location type unit]
|
||||
```
|
||||
12
example/ts-querylabels/go.mod
Normal file
12
example/ts-querylabels/go.mod
Normal file
@@ -0,0 +1,12 @@
|
||||
module github.com/redis/go-redis/example/ts-querylabels
|
||||
|
||||
go 1.24
|
||||
|
||||
replace github.com/redis/go-redis/v9 => ../..
|
||||
|
||||
require github.com/redis/go-redis/v9 v9.21.0
|
||||
|
||||
require (
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
)
|
||||
20
example/ts-querylabels/go.sum
Normal file
20
example/ts-querylabels/go.sum
Normal file
@@ -0,0 +1,20 @@
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
89
example/ts-querylabels/main.go
Normal file
89
example/ts-querylabels/main.go
Normal file
@@ -0,0 +1,89 @@
|
||||
// Example demonstrating TS.QUERYLABELS: discovering label names and label
|
||||
// values across time series.
|
||||
//
|
||||
// TS.QUERYLABELS (Redis 8.10+) completes the drill-down flow dashboarding
|
||||
// tools such as Grafana use to populate variable dropdowns:
|
||||
//
|
||||
// 1. TSQueryLabels - all label names for a group of series
|
||||
// 2. TSQueryLabelValues - all values of a chosen label for that group
|
||||
// 3. TSQueryIndex - all keys matching the fully-selected filter
|
||||
//
|
||||
// Requires Redis 8.10+ with the TimeSeries module.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
|
||||
rdb := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer rdb.Close()
|
||||
|
||||
keys := seed(ctx, rdb)
|
||||
defer rdb.Del(ctx, keys...)
|
||||
|
||||
// Step 1: first dropdown - which labels does the sensor group carry?
|
||||
// The reply is unordered, deduplicated, and includes the label used in
|
||||
// the filter itself ("type").
|
||||
labels, err := rdb.TSQueryLabels(ctx, []string{"type=sensor"}).Result()
|
||||
if err != nil {
|
||||
log.Fatalf("TS.QUERYLABELS LABELS: %v", err)
|
||||
}
|
||||
fmt.Printf("label names for type=sensor: %v\n", labels)
|
||||
|
||||
// Step 2: second dropdown - which values does "location" take in that
|
||||
// group? Series without the label contribute nothing.
|
||||
locations, err := rdb.TSQueryLabelValues(ctx, "location", []string{"type=sensor"}).Result()
|
||||
if err != nil {
|
||||
log.Fatalf("TS.QUERYLABELS VALUES: %v", err)
|
||||
}
|
||||
fmt.Printf("locations for type=sensor: %v\n", locations)
|
||||
|
||||
// Step 3: third dropdown - the keys matching the fully-selected filter
|
||||
// (existing TS.QUERYINDEX).
|
||||
series, err := rdb.TSQueryIndex(ctx, []string{"type=sensor", "location=kitchen"}).Result()
|
||||
if err != nil {
|
||||
log.Fatalf("TS.QUERYINDEX: %v", err)
|
||||
}
|
||||
fmt.Printf("kitchen sensor series: %v\n", series)
|
||||
|
||||
// A label that no matching series carries yields an empty reply, not an
|
||||
// error - dashboards keep working.
|
||||
missing, err := rdb.TSQueryLabelValues(ctx, "rack", []string{"type=sensor"}).Result()
|
||||
if err != nil {
|
||||
log.Fatalf("TS.QUERYLABELS VALUES rack: %v", err)
|
||||
}
|
||||
fmt.Printf("values of an absent label: %d\n", len(missing))
|
||||
|
||||
// Passing no filters queries every indexed series in the database.
|
||||
all, err := rdb.TSQueryLabels(ctx, nil).Result()
|
||||
if err != nil {
|
||||
log.Fatalf("TS.QUERYLABELS LABELS (no filter): %v", err)
|
||||
}
|
||||
fmt.Printf("label names, all series: %v\n", all)
|
||||
}
|
||||
|
||||
func seed(ctx context.Context, rdb *redis.Client) []string {
|
||||
series := map[string]map[string]string{
|
||||
"ts:temp:kitchen": {"type": "sensor", "location": "kitchen", "unit": "celsius"},
|
||||
"ts:temp:bedroom": {"type": "sensor", "location": "bedroom", "unit": "celsius"},
|
||||
"ts:hum:kitchen": {"type": "sensor", "location": "kitchen"},
|
||||
"ts:build:count": {"type": "counter"},
|
||||
}
|
||||
keys := make([]string, 0, len(series))
|
||||
for key, labels := range series {
|
||||
if err := rdb.TSCreateWithArgs(ctx, key, &redis.TSOptions{Labels: labels}).Err(); err != nil {
|
||||
log.Fatalf("TS.CREATE %s (requires Redis 8.10+ with timeseries): %v", key, err)
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
@@ -31,6 +31,8 @@ type TimeseriesCmdable interface {
|
||||
TSInfoWithArgs(ctx context.Context, key string, options *TSInfoOptions) *MapStringInterfaceCmd
|
||||
TSMAdd(ctx context.Context, ktvSlices [][]interface{}) *IntSliceCmd
|
||||
TSQueryIndex(ctx context.Context, filterExpr []string) *StringSliceCmd
|
||||
TSQueryLabels(ctx context.Context, filterExpr []string) *StringSliceCmd
|
||||
TSQueryLabelValues(ctx context.Context, label string, filterExpr []string) *StringSliceCmd
|
||||
TSRevRange(ctx context.Context, key string, fromTimestamp int, toTimestamp int) *TSTimestampValueSliceCmd
|
||||
TSRevRangeWithArgs(ctx context.Context, key string, fromTimestamp int, toTimestamp int, options *TSRevRangeOptions) *TSTimestampValueSliceCmd
|
||||
TSRange(ctx context.Context, key string, fromTimestamp int, toTimestamp int) *TSTimestampValueSliceCmd
|
||||
@@ -717,6 +719,53 @@ func (c cmdable) TSQueryIndex(ctx context.Context, filterExpr []string) *StringS
|
||||
return cmd
|
||||
}
|
||||
|
||||
// TSQueryLabels - Returns the set of label names present on the time series
|
||||
// matching the filter expressions. Passing no filter expressions queries all
|
||||
// indexed series. The reply is unordered and already deduplicated by the
|
||||
// server; it includes the label names used in the filter itself, and an
|
||||
// empty reply is a valid result, not an error.
|
||||
// filterExpr uses the same filter language as TSQueryIndex and is passed to
|
||||
// the server verbatim. Available since Redis 8.10.
|
||||
// For more information - https://redis.io/commands/ts.querylabels/
|
||||
func (c cmdable) TSQueryLabels(ctx context.Context, filterExpr []string) *StringSliceCmd {
|
||||
args := []interface{}{"TS.QUERYLABELS", "LABELS"}
|
||||
args = appendTSFilter(args, filterExpr)
|
||||
cmd := NewStringSliceCmd(ctx, args...)
|
||||
_ = c(ctx, cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// TSQueryLabelValues - Returns the set of values assigned to the given label
|
||||
// name across the time series matching the filter expressions. Passing no
|
||||
// filter expressions queries all indexed series. The label name is matched
|
||||
// byte-exactly; a label present on no matching series yields an empty reply,
|
||||
// not an error. The reply is unordered and already deduplicated by the
|
||||
// server.
|
||||
// filterExpr uses the same filter language as TSQueryIndex and is passed to
|
||||
// the server verbatim. Available since Redis 8.10.
|
||||
// For more information - https://redis.io/commands/ts.querylabels/
|
||||
func (c cmdable) TSQueryLabelValues(ctx context.Context, label string, filterExpr []string) *StringSliceCmd {
|
||||
args := []interface{}{"TS.QUERYLABELS", "VALUES", label}
|
||||
args = appendTSFilter(args, filterExpr)
|
||||
cmd := NewStringSliceCmd(ctx, args...)
|
||||
_ = c(ctx, cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// appendTSFilter appends the FILTER token followed by the filter expressions,
|
||||
// or nothing when no expressions are given: the server rejects a bare FILTER
|
||||
// token, and omitting it is the documented way to query all indexed series.
|
||||
func appendTSFilter(args []interface{}, filterExpr []string) []interface{} {
|
||||
if len(filterExpr) == 0 {
|
||||
return args
|
||||
}
|
||||
args = append(args, "FILTER")
|
||||
for _, f := range filterExpr {
|
||||
args = append(args, f)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// TSRevRange - Returns a range of samples from a time-series key in reverse order.
|
||||
// For more information - https://redis.io/commands/ts.revrange/
|
||||
func (c cmdable) TSRevRange(ctx context.Context, key string, fromTimestamp int, toTimestamp int) *TSTimestampValueSliceCmd {
|
||||
|
||||
108
timeseries_querylabels_integration_test.go
Normal file
108
timeseries_querylabels_integration_test.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package redis_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
. "github.com/bsm/ginkgo/v2"
|
||||
. "github.com/bsm/gomega"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// TS.QUERYLABELS ships in RedisTimeSeries 8.10. The specs gate on
|
||||
// SkipBeforeRedisVersion, which compares major.minor as integers, and
|
||||
// exercise both command forms under RESP2 (array reply) and RESP3 (set
|
||||
// reply) against the suite's standard server.
|
||||
var _ = Describe("TS.QUERYLABELS", Label("timeseries", "tsquerylabels"), func() {
|
||||
ctx := context.TODO()
|
||||
|
||||
for _, protocol := range []int{2, 3} {
|
||||
protocol := protocol
|
||||
|
||||
Describe(fmt.Sprintf("RESP%d", protocol), func() {
|
||||
var client *redis.Client
|
||||
|
||||
// The command aggregates over every indexed series in the
|
||||
// database, so scope assertions with a filter on a tag value
|
||||
// unique to this spec group.
|
||||
group := fmt.Sprintf("querylabels-test-resp%d", protocol)
|
||||
groupFilter := []string{"test_group=" + group}
|
||||
keys := []string{
|
||||
fmt.Sprintf("querylabels-test:%d:1", protocol),
|
||||
fmt.Sprintf("querylabels-test:%d:2", protocol),
|
||||
fmt.Sprintf("querylabels-test:%d:3", protocol),
|
||||
}
|
||||
|
||||
BeforeEach(func() {
|
||||
SkipBeforeRedisVersion("8.10", "TS.QUERYLABELS requires Redis 8.10+")
|
||||
client = redis.NewClient(&redis.Options{Addr: ":6379", Protocol: protocol})
|
||||
Expect(client.FlushDB(ctx).Err()).NotTo(HaveOccurred())
|
||||
|
||||
mustCreate := func(key string, labels map[string]string) {
|
||||
Expect(client.TSCreateWithArgs(ctx, key, &redis.TSOptions{Labels: labels}).Err()).NotTo(HaveOccurred())
|
||||
}
|
||||
mustCreate(keys[0], map[string]string{"test_group": group, "location": "kitchen", "unit": "celsius"})
|
||||
mustCreate(keys[1], map[string]string{"test_group": group, "location": "bedroom", "unit": "celsius"})
|
||||
mustCreate(keys[2], map[string]string{"test_group": group, "location": "kitchen"})
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
if client == nil {
|
||||
return
|
||||
}
|
||||
_ = client.Del(ctx, keys...).Err()
|
||||
Expect(client.Close()).NotTo(HaveOccurred())
|
||||
client = nil
|
||||
})
|
||||
|
||||
It("lists every distinct label name across the matching series", func() {
|
||||
// The reply includes the label name used in the filter
|
||||
// itself and is unordered and deduplicated.
|
||||
labels, err := client.TSQueryLabels(ctx, groupFilter).Result()
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(labels).To(ConsistOf("test_group", "location", "unit"))
|
||||
})
|
||||
|
||||
It("lists the deduplicated union of a label's values", func() {
|
||||
values, err := client.TSQueryLabelValues(ctx, "location", groupFilter).Result()
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(values).To(ConsistOf("kitchen", "bedroom"))
|
||||
|
||||
// Series without the label contribute nothing.
|
||||
values, err = client.TSQueryLabelValues(ctx, "unit", groupFilter).Result()
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(values).To(ConsistOf("celsius"))
|
||||
})
|
||||
|
||||
It("returns an empty reply for a label on no matching series", func() {
|
||||
values, err := client.TSQueryLabelValues(ctx, "no-such-label", groupFilter).Result()
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(values).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("matches label names byte-exactly", func() {
|
||||
// A case variant is a different label.
|
||||
values, err := client.TSQueryLabelValues(ctx, "Location", groupFilter).Result()
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(values).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("queries all indexed series when the filter is omitted", func() {
|
||||
labels, err := client.TSQueryLabels(ctx, nil).Result()
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
// FlushDB in BeforeEach makes this spec's series the only
|
||||
// indexed ones in the database.
|
||||
Expect(labels).To(ConsistOf("test_group", "location", "unit"))
|
||||
})
|
||||
|
||||
It("propagates server-side filter errors as-is", func() {
|
||||
// A filter list without an inclusive matcher is rejected by
|
||||
// the server (TSDB: prefix), not by the client.
|
||||
err := client.TSQueryLabels(ctx, []string{"test_group!=" + group}).Err()
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("TSDB:"))
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
75
timeseries_querylabels_unit_test.go
Normal file
75
timeseries_querylabels_unit_test.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package redis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestTSQueryLabelsArgs pins the wire format of both TS.QUERYLABELS forms.
|
||||
// The FILTER token must be emitted only when at least one filter expression
|
||||
// is given (the server rejects a bare FILTER), filter expressions must be
|
||||
// passed verbatim and in order, and the VALUES label must not be normalized.
|
||||
func TestTSQueryLabelsArgs(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
call func(c cmdable) Cmder
|
||||
want []interface{}
|
||||
}{
|
||||
{
|
||||
name: "labels_without_filter_omits_filter_token",
|
||||
call: func(c cmdable) Cmder { return c.TSQueryLabels(ctx, nil) },
|
||||
want: []interface{}{"TS.QUERYLABELS", "LABELS"},
|
||||
},
|
||||
{
|
||||
name: "labels_with_empty_filter_slice_omits_filter_token",
|
||||
call: func(c cmdable) Cmder { return c.TSQueryLabels(ctx, []string{}) },
|
||||
want: []interface{}{"TS.QUERYLABELS", "LABELS"},
|
||||
},
|
||||
{
|
||||
name: "labels_with_one_filter",
|
||||
call: func(c cmdable) Cmder { return c.TSQueryLabels(ctx, []string{"type=sensor"}) },
|
||||
want: []interface{}{"TS.QUERYLABELS", "LABELS", "FILTER", "type=sensor"},
|
||||
},
|
||||
{
|
||||
name: "labels_filters_verbatim_and_ordered",
|
||||
call: func(c cmdable) Cmder {
|
||||
return c.TSQueryLabels(ctx, []string{"type=sensor", "location!=", "l=(a,b)"})
|
||||
},
|
||||
want: []interface{}{"TS.QUERYLABELS", "LABELS", "FILTER", "type=sensor", "location!=", "l=(a,b)"},
|
||||
},
|
||||
{
|
||||
name: "values_without_filter_omits_filter_token",
|
||||
call: func(c cmdable) Cmder { return c.TSQueryLabelValues(ctx, "location", nil) },
|
||||
want: []interface{}{"TS.QUERYLABELS", "VALUES", "location"},
|
||||
},
|
||||
{
|
||||
name: "values_with_filters",
|
||||
call: func(c cmdable) Cmder {
|
||||
return c.TSQueryLabelValues(ctx, "location", []string{"type=sensor"})
|
||||
},
|
||||
want: []interface{}{"TS.QUERYLABELS", "VALUES", "location", "FILTER", "type=sensor"},
|
||||
},
|
||||
{
|
||||
name: "values_label_not_normalized",
|
||||
call: func(c cmdable) Cmder { return c.TSQueryLabelValues(ctx, " LoCaTiOn ", nil) },
|
||||
want: []interface{}{"TS.QUERYLABELS", "VALUES", " LoCaTiOn "},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var captured Cmder
|
||||
c := captureCmdable(&captured)
|
||||
cmd := tt.call(c)
|
||||
if cmd == nil {
|
||||
t.Fatalf("command builder returned nil")
|
||||
}
|
||||
if !reflect.DeepEqual(cmd.Args(), tt.want) {
|
||||
t.Errorf("args mismatch\n got: %#v\nwant: %#v", cmd.Args(), tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user