From 2c14ef8f250484e623d95a8dcec4aae867b0a77f Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Wed, 8 Jul 2026 13:50:11 +0100 Subject: [PATCH 01/28] DOC-6823 make sorted-set zrank/zadd_lex examples runnable in the interactive CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the try_it="false" opt-out from the zrank and zadd_lex examples so the "Try it" button and on-page terminal can run them. zrank is made fully self-contained: its CLI session and every client tab first recreate the post-deletion {Norem 10, Royce 10, Prickett 14} state, so ZRANK/ZREVRANK return 0/2 in a cold database. Adds the two missing Lettuce overrides (async + reactive) under local_examples/client-specific so all ten language tabs tell the same story. The dependency only ever bit the external Try-it button (fresh DB) and isolated single-step runs — the on-page terminal shares one session per page and auto-runs snippets top-to-bottom, so the chained tutorial already worked there. The built-in needs_prereq replay was no help for these two steps because it replays only the set-level prereq (the original six-racer ZADD), whereas zrank/zadd_lex depend on the mutated three-racer state left by zremrangebyscore; hence the explicit recreate rather than needs_prereq. The recreate has to sit inside the STEP_START block to show in the client tab (Go keeps its setup outside the markers). zadd_lex was left reset-free on purpose: in a cold sandbox its first ZADD reports 6 added rather than the documented 3, but it runs without error and every later line is correct, which is preferable to cluttering the lexicographic example with a reset. Learned: self-containment matters only for the Try-it button / isolated runs, not the shared-session on-page terminal Directive: don't add a state reset to the zadd_lex CLI example — the cold-sandbox 6-vs-3 ZADD count is an accepted cosmetic diff, not a bug Directive: the Lettuce reactive SortedSetExample exists only as a local_examples override — push it to redis/lettuce so their CI covers it Ticket: DOC-6823 Co-Authored-By: Claude Opus 4.8 (1M context) --- content/develop/data-types/sorted-sets.md | 9 +- .../lettuce-async/SortedSetExample.java | 244 ++++++++++++++++++ .../lettuce-reactive/SortedSetExample.java | 200 ++++++++++++++ local_examples/php/DtSortedSetsTest.php | 8 + local_examples/ruby/dt_sorted_sets.rb | 4 + local_examples/rust-async/dt-sorted-sets.rs | 10 + local_examples/rust-sync/dt-sorted-sets.rs | 9 + .../datatypes/sorted-sets/SortedSetExample.cs | 8 + .../sorted-sets/SortedSetsExample.java | 8 + .../tmp/datatypes/sorted-sets/dt-ss.js | 8 + .../tmp/datatypes/sorted-sets/dt_ss.py | 7 + .../datatypes/sorted-sets/ss_tutorial_test.go | 13 +- 12 files changed, 518 insertions(+), 10 deletions(-) create mode 100644 local_examples/client-specific/lettuce-async/SortedSetExample.java create mode 100644 local_examples/client-specific/lettuce-reactive/SortedSetExample.java diff --git a/content/develop/data-types/sorted-sets.md b/content/develop/data-types/sorted-sets.md index 51e3e67724..f8a0d88c90 100644 --- a/content/develop/data-types/sorted-sets.md +++ b/content/develop/data-types/sorted-sets.md @@ -151,7 +151,12 @@ position of an element in the set of ordered elements. The [`ZREVRANK`]({{< relref "/commands/zrevrank" >}}) command is also available in order to get the rank, considering the elements sorted in a descending way. -{{< clients-example set="ss_tutorial" step="zrank" description="Get member position: Use ZRANK and ZREVRANK to find a member's position in the sorted set (useful for leaderboards)" difficulty="intermediate" buildsUpon="zadd" try_it="false" >}} +{{< clients-example set="ss_tutorial" step="zrank" description="Get member position: Use ZRANK and ZREVRANK to find a member's position in the sorted set (useful for leaderboards)" difficulty="intermediate" buildsUpon="zadd" >}} +# Recreate the three remaining racers so this example runs on its own. +> DEL racer_scores +(integer) 1 +> ZADD racer_scores 10 "Norem" 10 "Royce" 14 "Prickett" +(integer) 3 > ZRANK racer_scores "Norem" (integer) 0 > ZREVRANK racer_scores "Norem" @@ -172,7 +177,7 @@ The main commands to operate with lexicographical ranges are [`ZRANGEBYLEX`]({{< For example, let's add again our list of famous racers, but this time using a score of zero for all the elements. We'll see that because of the sorted sets ordering rules, they are already sorted lexicographically. Using [`ZRANGEBYLEX`]({{< relref "/commands/zrangebylex" >}}) we can ask for lexicographical ranges: -{{< clients-example set="ss_tutorial" step="zadd_lex" description="Lexicographical queries: Add members with identical scores and use ZRANGEBYLEX to query by string range (enables generic indexing)" difficulty="intermediate" buildsUpon="zadd" try_it="false" >}} +{{< clients-example set="ss_tutorial" step="zadd_lex" description="Lexicographical queries: Add members with identical scores and use ZRANGEBYLEX to query by string range (enables generic indexing)" difficulty="intermediate" buildsUpon="zadd" >}} > ZADD racer_scores 0 "Norem" 0 "Sam-Bodden" 0 "Royce" 0 "Castilla" 0 "Prickett" 0 "Ford" (integer) 3 > ZRANGE racer_scores 0 -1 diff --git a/local_examples/client-specific/lettuce-async/SortedSetExample.java b/local_examples/client-specific/lettuce-async/SortedSetExample.java new file mode 100644 index 0000000000..4a0fc8ca96 --- /dev/null +++ b/local_examples/client-specific/lettuce-async/SortedSetExample.java @@ -0,0 +1,244 @@ +// EXAMPLE: ss_tutorial +package io.redis.examples.async; + +import io.lettuce.core.*; +import io.lettuce.core.api.async.RedisAsyncCommands; +import io.lettuce.core.api.StatefulRedisConnection; + +// REMOVE_START +import org.junit.jupiter.api.Test; +// REMOVE_END +import java.util.*; +import java.util.concurrent.CompletableFuture; +// REMOVE_START +import static org.assertj.core.api.Assertions.assertThat; +// REMOVE_END + +public class SortedSetExample { + + @Test + public void run() { + RedisClient redisClient = RedisClient.create("redis://localhost:6379"); + + try (StatefulRedisConnection connection = redisClient.connect()) { + RedisAsyncCommands asyncCommands = connection.async(); + // REMOVE_START + asyncCommands.del("racer_scores").toCompletableFuture().join(); + // REMOVE_END + + // STEP_START zadd + CompletableFuture zadd = asyncCommands.zadd("racer_scores", ScoredValue.just(10d, "Norem")) + .thenCompose(res1 -> { + System.out.println(res1); // >>> 1 + // REMOVE_START + assertThat(res1).isEqualTo(1); + // REMOVE_END + + return asyncCommands.zadd("racer_scores", ScoredValue.just(12d, "Castilla")); + }).thenCompose(res2 -> { + System.out.println(res2); // >>> 1 + // REMOVE_START + assertThat(res2).isEqualTo(1); + // REMOVE_END + + return asyncCommands.zadd("racer_scores", ScoredValue.just(8d, "Sam-Bodden"), + ScoredValue.just(10d, "Royce"), ScoredValue.just(6d, "Ford"), + ScoredValue.just(14d, "Prickett")); + }) + // REMOVE_START + .thenApply(res -> { + assertThat(res).isEqualTo(4); + return res; + }) + // REMOVE_END + .thenAccept(System.out::println) // >>> 4 + .toCompletableFuture(); + // STEP_END + zadd.join(); + + // STEP_START zrange + CompletableFuture zrange = asyncCommands.zrange("racer_scores", 0, -1).thenCompose(res3 -> { + System.out.println(res3); + // >>> [Ford, Sam-Bodden, Norem, Royce, Castilla, Prickett] + // REMOVE_START + assertThat(res3.toString()).isEqualTo("[Ford, Sam-Bodden, Norem, Royce, Castilla, Prickett]"); + // REMOVE_END + + return asyncCommands.zrevrange("racer_scores", 0, -1); + }) + // REMOVE_START + .thenApply((List res) -> { + assertThat(res.toString()).isEqualTo("[Prickett, Castilla, Royce, Norem, Sam-Bodden, Ford]"); + return res; + }) + // REMOVE_END + .thenAccept(System.out::println) + // >>> [Prickett, Castilla, Royce, Norem, Sam-Bodden, Ford] + .toCompletableFuture(); + // STEP_END + zrange.join(); + + // STEP_START zrange_withscores + CompletableFuture zrangeWithScores = asyncCommands.zrangeWithScores("racer_scores", 0, -1) + // REMOVE_START + .thenApply((List> res) -> { + assertThat(res.toString()).isEqualTo("[ScoredValue[6.000000, Ford], ScoredValue[8.000000, Sam-Bodden]," + + " ScoredValue[10.000000, Norem], ScoredValue[10.000000, Royce]," + + " ScoredValue[12.000000, Castilla], ScoredValue[14.000000, Prickett]]"); + return res; + }) + // REMOVE_END + .thenAccept(System.out::println) + // >>> [ScoredValue[6.000000, Ford], ScoredValue[8.000000, Sam-Bodden]... + .toCompletableFuture(); + // STEP_END + zrangeWithScores.join(); + + // STEP_START zrangebyscore + CompletableFuture zrangebyscore = asyncCommands + .zrangebyscore("racer_scores", Range.create(Double.MIN_VALUE, 10)) + // REMOVE_START + .thenApply(res -> { + assertThat(res.toString()).isEqualTo("[Ford, Sam-Bodden, Norem, Royce]"); + return res; + }) + // REMOVE_END + .thenAccept(System.out::println) + // >>> [Ford, Sam-Bodden, Norem, Royce] + .toCompletableFuture(); + // STEP_END + zrangebyscore.join(); + + // STEP_START zremrangebyscore + CompletableFuture zremrangebyscore = asyncCommands.zrem("racer_scores", "Castilla").thenCompose(res4 -> { + System.out.println(res4); // >>> 1 + // REMOVE_START + assertThat(res4).isEqualTo(1); + // REMOVE_END + + return asyncCommands.zremrangebyscore("racer_scores", Range.create(Double.MIN_VALUE, 9)); + }).thenCompose(res5 -> { + System.out.println(res5); // >>> 2 + // REMOVE_START + assertThat(res5).isEqualTo(2); + // REMOVE_END + + return asyncCommands.zrange("racer_scores", 0, -1); + }) + // REMOVE_START + .thenApply(res -> { + assertThat(res.toString()).isEqualTo("[Norem, Royce, Prickett]"); + return res; + }) + // REMOVE_END + .thenAccept(System.out::println) + // >>> [Norem, Royce, Prickett] + .toCompletableFuture(); + // STEP_END + zremrangebyscore.join(); + + // STEP_START zrank + // Recreate the three remaining racers so this example runs on its own. + CompletableFuture zrank = asyncCommands.del("racer_scores") + .thenCompose(delRes -> asyncCommands.zadd("racer_scores", ScoredValue.just(10d, "Norem"), + ScoredValue.just(10d, "Royce"), ScoredValue.just(14d, "Prickett"))) + .thenCompose(addRes -> asyncCommands.zrank("racer_scores", "Norem")) + .thenCompose(res6 -> { + System.out.println(res6); // >>> 0 + // REMOVE_START + assertThat(res6).isZero(); + // REMOVE_END + + return asyncCommands.zrevrank("racer_scores", "Norem"); + }) + // REMOVE_START + .thenApply(res -> { + assertThat(res).isEqualTo(2); + return res; + }) + // REMOVE_END + .thenAccept(System.out::println) // >>> 2 + .toCompletableFuture(); + // STEP_END + zrank.join(); + + // STEP_START zadd_lex + CompletableFuture zaddLex = asyncCommands.zadd("racer_scores", ScoredValue.just(0d, "Norem"), + ScoredValue.just(0d, "Sam-Bodden"), ScoredValue.just(0d, "Royce"), ScoredValue.just(0d, "Castilla"), + ScoredValue.just(0d, "Prickett"), ScoredValue.just(0d, "Ford")).thenCompose(res7 -> { + System.out.println(res7); // >>> 3 + // REMOVE_START + assertThat(res7).isEqualTo(3); + // REMOVE_END + + return asyncCommands.zrange("racer_scores", 0, -1); + }).thenCompose(res8 -> { + System.out.println(res8); + // >>> [Castilla, Ford, Norem, Prickett, Royce, Sam-Bodden] + // REMOVE_START + assertThat(res8.toString()).isEqualTo("[Castilla, Ford, Norem, Prickett, Royce, Sam-Bodden]"); + // REMOVE_END + + return asyncCommands.zrangebylex("racer_scores", Range.create("A", "L")); + }) + // REMOVE_START + .thenApply(res -> { + assertThat(res.toString()).isEqualTo("[Castilla, Ford]"); + return res; + }) + // REMOVE_END + .thenAccept(System.out::println) + // >>> [Castilla, Ford] + .toCompletableFuture(); + // STEP_END + zaddLex.join(); + + // STEP_START leaderboard + CompletableFuture leaderboard = asyncCommands.zadd("racer_scores", ScoredValue.just(100, "Wood")) + .thenCompose(res9 -> { + System.out.println(res9); // >>> 1 + // REMOVE_START + assertThat(res9).isEqualTo(1); + // REMOVE_END + + return asyncCommands.zadd("racer_scores", ScoredValue.just(100, "Henshaw")); + }).thenCompose(res10 -> { + System.out.println(res10); // >>> 1 + // REMOVE_START + assertThat(res10).isEqualTo(1); + // REMOVE_END + + return asyncCommands.zadd("racer_scores", ScoredValue.just(150, "Henshaw")); + }).thenCompose(res11 -> { + System.out.println(res11); // >>> 0 + // REMOVE_START + assertThat(res11).isZero(); + // REMOVE_END + + return asyncCommands.zincrby("racer_scores", 50, "Wood"); + }).thenCompose(res12 -> { + System.out.println(res12); // >>> 150 + // REMOVE_START + assertThat(res12).isEqualTo(150); + // REMOVE_END + + return asyncCommands.zincrby("racer_scores", 50, "Henshaw"); + }) + // REMOVE_START + .thenApply(res -> { + assertThat(res).isEqualTo(200); + return res; + }) + // REMOVE_END + .thenAccept(System.out::println) // >>> 200 + .toCompletableFuture(); + // STEP_END + leaderboard.join(); + // HIDE_START + } finally { + redisClient.shutdown(); + } + } + +} +// HIDE_END diff --git a/local_examples/client-specific/lettuce-reactive/SortedSetExample.java b/local_examples/client-specific/lettuce-reactive/SortedSetExample.java new file mode 100644 index 0000000000..cc74de8389 --- /dev/null +++ b/local_examples/client-specific/lettuce-reactive/SortedSetExample.java @@ -0,0 +1,200 @@ +// EXAMPLE: ss_tutorial +package io.redis.examples.reactive; + +import io.lettuce.core.*; +import io.lettuce.core.api.reactive.RedisReactiveCommands; +import io.lettuce.core.api.StatefulRedisConnection; +// REMOVE_START +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +// REMOVE_END + +import reactor.core.publisher.Mono; + +import java.util.*; + +public class SortedSetExample { + + // REMOVE_START + @Test + // REMOVE_END + public void run() { + RedisClient redisClient = RedisClient.create("redis://localhost:6379"); + + try (StatefulRedisConnection connection = redisClient.connect()) { + RedisReactiveCommands reactiveCommands = connection.reactive(); + // REMOVE_START + reactiveCommands.del("racer_scores").block(); + // REMOVE_END + + // STEP_START zadd + Mono zadd = reactiveCommands.zadd("racer_scores", ScoredValue.just(10d, "Norem")).doOnNext(result -> { + System.out.println(result); // >>> 1 + // REMOVE_START + assertThat(result).isEqualTo(1); + // REMOVE_END + }).flatMap(v -> reactiveCommands.zadd("racer_scores", ScoredValue.just(12d, "Castilla"))).doOnNext(result -> { + System.out.println(result); // >>> 1 + // REMOVE_START + assertThat(result).isEqualTo(1); + // REMOVE_END + }).flatMap(v -> reactiveCommands.zadd("racer_scores", ScoredValue.just(8d, "Sam-Bodden"), + ScoredValue.just(10d, "Royce"), ScoredValue.just(6d, "Ford"), + ScoredValue.just(14d, "Prickett"))).doOnNext(result -> { + System.out.println(result); // >>> 4 + // REMOVE_START + assertThat(result).isEqualTo(4); + // REMOVE_END + }).then(); + // STEP_END + zadd.block(); + + // STEP_START zrange + Mono zrange = reactiveCommands.zrange("racer_scores", 0, -1).collectList().doOnNext(result -> { + System.out.println(result); + // >>> [Ford, Sam-Bodden, Norem, Royce, Castilla, Prickett] + // REMOVE_START + assertThat(result.toString()).isEqualTo("[Ford, Sam-Bodden, Norem, Royce, Castilla, Prickett]"); + // REMOVE_END + }).flatMap(v -> reactiveCommands.zrevrange("racer_scores", 0, -1).collectList()).doOnNext(result -> { + System.out.println(result); + // >>> [Prickett, Castilla, Royce, Norem, Sam-Bodden, Ford] + // REMOVE_START + assertThat(result.toString()).isEqualTo("[Prickett, Castilla, Royce, Norem, Sam-Bodden, Ford]"); + // REMOVE_END + }).then(); + // STEP_END + zrange.block(); + + // STEP_START zrange_withscores + Mono zrangeWithScores = reactiveCommands.zrangeWithScores("racer_scores", 0, -1).collectList() + .doOnNext(result -> { + System.out.println(result); + // >>> [ScoredValue[6.000000, Ford], ScoredValue[8.000000, Sam-Bodden]... + // REMOVE_START + assertThat(result.toString()) + .isEqualTo("[ScoredValue[6.000000, Ford], ScoredValue[8.000000, Sam-Bodden]," + + " ScoredValue[10.000000, Norem], ScoredValue[10.000000, Royce]," + + " ScoredValue[12.000000, Castilla], ScoredValue[14.000000, Prickett]]"); + // REMOVE_END + }).then(); + // STEP_END + zrangeWithScores.block(); + + // STEP_START zrangebyscore + Mono zrangebyscore = reactiveCommands + .zrangebyscore("racer_scores", Range.create(Double.MIN_VALUE, 10)).collectList().doOnNext(result -> { + System.out.println(result); // >>> [Ford, Sam-Bodden, Norem, Royce] + // REMOVE_START + assertThat(result.toString()).isEqualTo("[Ford, Sam-Bodden, Norem, Royce]"); + // REMOVE_END + }).then(); + // STEP_END + zrangebyscore.block(); + + // STEP_START zremrangebyscore + Mono zremrangebyscore = reactiveCommands.zrem("racer_scores", "Castilla").doOnNext(result -> { + System.out.println(result); // >>> 1 + // REMOVE_START + assertThat(result).isEqualTo(1); + // REMOVE_END + }).flatMap(v -> reactiveCommands.zremrangebyscore("racer_scores", Range.create(Double.MIN_VALUE, 9))) + .doOnNext(result -> { + System.out.println(result); // >>> 2 + // REMOVE_START + assertThat(result).isEqualTo(2); + // REMOVE_END + }).flatMap(v -> reactiveCommands.zrange("racer_scores", 0, -1).collectList()).doOnNext(result -> { + System.out.println(result); // >>> [Norem, Royce, Prickett] + // REMOVE_START + assertThat(result.toString()).isEqualTo("[Norem, Royce, Prickett]"); + // REMOVE_END + }).then(); + // STEP_END + zremrangebyscore.block(); + + // STEP_START zrank + // Recreate the three remaining racers so this example runs on its own. + Mono zrank = reactiveCommands.del("racer_scores") + .flatMap(v -> reactiveCommands.zadd("racer_scores", ScoredValue.just(10d, "Norem"), + ScoredValue.just(10d, "Royce"), ScoredValue.just(14d, "Prickett"))) + .flatMap(v -> reactiveCommands.zrank("racer_scores", "Norem")).doOnNext(result -> { + System.out.println(result); // >>> 0 + // REMOVE_START + assertThat(result).isZero(); + // REMOVE_END + }).flatMap(v -> reactiveCommands.zrevrank("racer_scores", "Norem")).doOnNext(result -> { + System.out.println(result); // >>> 2 + // REMOVE_START + assertThat(result).isEqualTo(2); + // REMOVE_END + }).then(); + // STEP_END + zrank.block(); + + // STEP_START zadd_lex + Mono zaddLex = reactiveCommands + .zadd("racer_scores", ScoredValue.just(0d, "Norem"), ScoredValue.just(0d, "Sam-Bodden"), + ScoredValue.just(0d, "Royce"), ScoredValue.just(0d, "Castilla"), + ScoredValue.just(0d, "Prickett"), ScoredValue.just(0d, "Ford")) + .doOnNext(result -> { + System.out.println(result); // >>> 3 + // REMOVE_START + assertThat(result).isEqualTo(3); + // REMOVE_END + }).flatMap(v -> reactiveCommands.zrange("racer_scores", 0, -1).collectList()).doOnNext(result -> { + System.out.println(result); + // >>> [Castilla, Ford, Norem, Prickett, Royce, Sam-Bodden] + // REMOVE_START + assertThat(result.toString()).isEqualTo("[Castilla, Ford, Norem, Prickett, Royce, Sam-Bodden]"); + // REMOVE_END + }).flatMap(v -> reactiveCommands.zrangebylex("racer_scores", Range.create("A", "L")).collectList()) + .doOnNext(result -> { + System.out.println(result); // >>> [Castilla, Ford] + // REMOVE_START + assertThat(result.toString()).isEqualTo("[Castilla, Ford]"); + // REMOVE_END + }).then(); + // STEP_END + zaddLex.block(); + + // STEP_START leaderboard + Mono leaderboard = reactiveCommands.zadd("racer_scores", ScoredValue.just(100, "Wood")) + .doOnNext(result -> { + System.out.println(result); // >>> 1 + // REMOVE_START + assertThat(result).isEqualTo(1); + // REMOVE_END + }).flatMap(v -> reactiveCommands.zadd("racer_scores", ScoredValue.just(100, "Henshaw"))) + .doOnNext(result -> { + System.out.println(result); // >>> 1 + // REMOVE_START + assertThat(result).isEqualTo(1); + // REMOVE_END + }).flatMap(v -> reactiveCommands.zadd("racer_scores", ScoredValue.just(150, "Henshaw"))) + .doOnNext(result -> { + System.out.println(result); // >>> 0 + // REMOVE_START + assertThat(result).isZero(); + // REMOVE_END + }).flatMap(v -> reactiveCommands.zincrby("racer_scores", 50, "Wood")).doOnNext(result -> { + System.out.println(result); // >>> 150 + // REMOVE_START + assertThat(result).isEqualTo(150); + // REMOVE_END + }).flatMap(v -> reactiveCommands.zincrby("racer_scores", 50, "Henshaw")).doOnNext(result -> { + System.out.println(result); // >>> 200 + // REMOVE_START + assertThat(result).isEqualTo(200); + // REMOVE_END + }).then(); + // STEP_END + leaderboard.block(); + // HIDE_START + } finally { + redisClient.shutdown(); + } + } + +} +// HIDE_END diff --git a/local_examples/php/DtSortedSetsTest.php b/local_examples/php/DtSortedSetsTest.php index e7c596e90b..18e7d95c8a 100644 --- a/local_examples/php/DtSortedSetsTest.php +++ b/local_examples/php/DtSortedSetsTest.php @@ -105,6 +105,14 @@ public function testDtSortedSet() { // REMOVE_END // STEP_START zrank + // Recreate the three remaining racers so this example runs on its own. + $r->del('racer_scores'); + $r->zadd('racer_scores', [ + 'Norem' => 10, + 'Royce' => 10, + 'Prickett' => 14, + ]); + $res11 = $r->zrank('racer_scores', 'Norem'); echo $res11 . PHP_EOL; // >>> 0 diff --git a/local_examples/ruby/dt_sorted_sets.rb b/local_examples/ruby/dt_sorted_sets.rb index 7392e7ccfe..8da3f9bfe4 100644 --- a/local_examples/ruby/dt_sorted_sets.rb +++ b/local_examples/ruby/dt_sorted_sets.rb @@ -95,6 +95,10 @@ def assert_equal(expected, actual) # REMOVE_END # STEP_START zrank +# Recreate the three remaining racers so this example runs on its own. +r.del('racer_scores') +r.zadd('racer_scores', [[10, 'Norem'], [10, 'Royce'], [14, 'Prickett']]) + res11 = r.zrank('racer_scores', 'Norem') puts res11 # 0 diff --git a/local_examples/rust-async/dt-sorted-sets.rs b/local_examples/rust-async/dt-sorted-sets.rs index d20a482ba4..68a4d2ed61 100644 --- a/local_examples/rust-async/dt-sorted-sets.rs +++ b/local_examples/rust-async/dt-sorted-sets.rs @@ -155,6 +155,16 @@ mod tests { // STEP_END // STEP_START zrank + // Recreate the three remaining racers so this example runs on its own. + let _: () = r.del("racer_scores").await.expect("Failed to del"); + let _: usize = r + .zadd_multiple( + "racer_scores", + &[(10, "Norem"), (10, "Royce"), (14, "Prickett")], + ) + .await + .expect("Failed to zadd"); + if let Ok(res) = r.zrank("racer_scores", "Norem").await { let res: Option = res; if let Some(res) = res { diff --git a/local_examples/rust-sync/dt-sorted-sets.rs b/local_examples/rust-sync/dt-sorted-sets.rs index f4dd649986..708557fa90 100644 --- a/local_examples/rust-sync/dt-sorted-sets.rs +++ b/local_examples/rust-sync/dt-sorted-sets.rs @@ -152,6 +152,15 @@ mod sorted_set_tests { // STEP_END // STEP_START zrank + // Recreate the three remaining racers so this example runs on its own. + let _: () = r.del("racer_scores").expect("Failed to del"); + let _: usize = r + .zadd_multiple( + "racer_scores", + &[(10, "Norem"), (10, "Royce"), (14, "Prickett")], + ) + .expect("Failed to zadd"); + if let Ok(res) = r.zrank("racer_scores", "Norem") { let res: Option = res; if let Some(res) = res { diff --git a/local_examples/tmp/datatypes/sorted-sets/SortedSetExample.cs b/local_examples/tmp/datatypes/sorted-sets/SortedSetExample.cs index 3755cc9db5..7f15deee1e 100644 --- a/local_examples/tmp/datatypes/sorted-sets/SortedSetExample.cs +++ b/local_examples/tmp/datatypes/sorted-sets/SortedSetExample.cs @@ -120,6 +120,14 @@ public void Run() //REMOVE_END //STEP_START zrank + // Recreate the three remaining racers so this example runs on its own. + db.KeyDelete("racer_scores"); + db.SortedSetAdd("racer_scores", [ + new("Norem", 10), + new("Royce", 10), + new("Prickett", 14) + ]); + long? res11 = db.SortedSetRank("racer_scores", "Norem"); Console.WriteLine(res11); // >>> 0 //REMOVE_START diff --git a/local_examples/tmp/datatypes/sorted-sets/SortedSetsExample.java b/local_examples/tmp/datatypes/sorted-sets/SortedSetsExample.java index 6d1b801933..ded430e6e8 100644 --- a/local_examples/tmp/datatypes/sorted-sets/SortedSetsExample.java +++ b/local_examples/tmp/datatypes/sorted-sets/SortedSetsExample.java @@ -78,6 +78,14 @@ public void run() { //REMOVE_END //STEP_START zrank + // Recreate the three remaining racers so this example runs on its own. + jedis.del("racer_scores"); + jedis.zadd("racer_scores", new HashMap() {{ + put("Norem", 10d); + put("Royce", 10d); + put("Prickett", 14d); + }}); + long res11 = jedis.zrank("racer_scores", "Norem"); System.out.println(res11); // >>> 0 diff --git a/local_examples/tmp/datatypes/sorted-sets/dt-ss.js b/local_examples/tmp/datatypes/sorted-sets/dt-ss.js index b3ed163bbd..4f5b362ac8 100644 --- a/local_examples/tmp/datatypes/sorted-sets/dt-ss.js +++ b/local_examples/tmp/datatypes/sorted-sets/dt-ss.js @@ -99,6 +99,14 @@ console.assert(count2 === 3); // REMOVE_END // STEP_START zrank +// Recreate the three remaining racers so this example runs on its own. +await client.del('racer_scores'); +await client.zAdd('racer_scores', [ + { score: 10, value: 'Norem' }, + { score: 10, value: 'Royce' }, + { score: 14, value: 'Prickett' } +]); + const res11 = await client.zRank('racer_scores', 'Norem'); console.log(res11); // >>> 0 diff --git a/local_examples/tmp/datatypes/sorted-sets/dt_ss.py b/local_examples/tmp/datatypes/sorted-sets/dt_ss.py index 7392856bc3..f900b170f6 100644 --- a/local_examples/tmp/datatypes/sorted-sets/dt_ss.py +++ b/local_examples/tmp/datatypes/sorted-sets/dt_ss.py @@ -73,6 +73,13 @@ # REMOVE_END # STEP_START zrank +# Recreate the three remaining racers so this example runs on its own. +r.delete("racer_scores") +r.zadd( + "racer_scores", + {"Norem": 10, "Royce": 10, "Prickett": 14}, +) + res11 = r.zrank("racer_scores", "Norem") print(res11) # >>> 0 diff --git a/local_examples/tmp/datatypes/sorted-sets/ss_tutorial_test.go b/local_examples/tmp/datatypes/sorted-sets/ss_tutorial_test.go index f692e597ac..50656c4d4a 100644 --- a/local_examples/tmp/datatypes/sorted-sets/ss_tutorial_test.go +++ b/local_examples/tmp/datatypes/sorted-sets/ss_tutorial_test.go @@ -283,20 +283,17 @@ func ExampleClient_zrank() { // REMOVE_START // start with fresh database rdb.FlushDB(ctx) - rdb.Del(ctx, "racer_scores") // REMOVE_END - _, err := rdb.ZAdd(ctx, "racer_scores", + // STEP_START zrank + // Recreate the three remaining racers so this example runs on its own. + rdb.Del(ctx, "racer_scores") + rdb.ZAdd(ctx, "racer_scores", redis.Z{Member: "Norem", Score: 10}, redis.Z{Member: "Royce", Score: 10}, redis.Z{Member: "Prickett", Score: 14}, - ).Result() + ) - if err != nil { - panic(err) - } - - // STEP_START zrank res11, err := rdb.ZRank(ctx, "racer_scores", "Norem").Result() if err != nil { From 2f9945552f14486387a043e96a09a3a8f1277f64 Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Wed, 8 Jul 2026 14:31:47 +0100 Subject: [PATCH 02/28] DOC-6823 make string setnx_xx example runnable in the interactive CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes try_it="false" from the setnx_xx example and prepends a recreate so it's self-contained for the "Try it" button and isolated single-step runs. Simpler than the sorted-set zrank fix: the only precondition is that bike:1 exists, and SET always returns OK, so a single re-establishing SET suffices — no DEL and no run-order-dependent output line. Adds the two missing Lettuce string overrides (async + reactive). All eleven client examples for this set (redis-cli plus 10 libraries) were run against a live Redis via build/example-test-harness and pass with their in-file assertions. Directive: the Lettuce string overrides duplicate upstream — push them to redis/lettuce so their CI covers them Ticket: DOC-6823 Co-Authored-By: Claude Opus 4.8 (1M context) --- content/develop/data-types/strings/_index.md | 5 +- .../lettuce-async/StringExample.java | 139 ++++++++++++++++++ .../lettuce-reactive/StringExample.java | 107 ++++++++++++++ local_examples/php/DtStringTest.php | 3 + local_examples/ruby/dt_string.rb | 3 + local_examples/rust-async/dt-string.rs | 3 + local_examples/rust-sync/dt-string.rs | 3 + .../tmp/datatypes/strings/StringExample.java | 3 + .../tmp/datatypes/strings/StringSnippets.cs | 3 + .../tmp/datatypes/strings/dt-string.js | 3 + .../tmp/datatypes/strings/dt_string.py | 3 + .../datatypes/strings/string_example_test.go | 4 +- 12 files changed, 277 insertions(+), 2 deletions(-) create mode 100644 local_examples/client-specific/lettuce-async/StringExample.java create mode 100644 local_examples/client-specific/lettuce-reactive/StringExample.java diff --git a/content/develop/data-types/strings/_index.md b/content/develop/data-types/strings/_index.md index b67f681ef3..b237efccd9 100644 --- a/content/develop/data-types/strings/_index.md +++ b/content/develop/data-types/strings/_index.md @@ -47,7 +47,10 @@ The [`SET`]({{< relref "/commands/set" >}}) command has interesting options that arguments. For example, I may ask [`SET`]({{< relref "/commands/set" >}}) to fail if the key already exists, or the opposite, that it only succeed if the key already exists: -{{< clients-example set="set_tutorial" step="setnx_xx" description="Conditional SET operations: Use NX and XX options to control key existence when you need atomic compare-and-set behavior" difficulty="intermediate" buildsUpon="set_get" try_it="false" >}} +{{< clients-example set="set_tutorial" step="setnx_xx" description="Conditional SET operations: Use NX and XX options to control key existence when you need atomic compare-and-set behavior" difficulty="intermediate" buildsUpon="set_get" >}} +# Recreate the bike:1 key so this example runs on its own. +> SET bike:1 Deimos +OK > SET bike:1 bike NX (nil) > SET bike:1 bike XX diff --git a/local_examples/client-specific/lettuce-async/StringExample.java b/local_examples/client-specific/lettuce-async/StringExample.java new file mode 100644 index 0000000000..767848868f --- /dev/null +++ b/local_examples/client-specific/lettuce-async/StringExample.java @@ -0,0 +1,139 @@ +// EXAMPLE: set_tutorial +package io.redis.examples.async; + +import io.lettuce.core.*; +import io.lettuce.core.api.async.RedisAsyncCommands; +import io.lettuce.core.api.StatefulRedisConnection; + +// REMOVE_START +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +// REMOVE_END + +import java.util.*; +import java.util.concurrent.CompletableFuture; + +public class StringExample { + + // REMOVE_START + @Test + // REMOVE_END + public void run() { + RedisClient redisClient = RedisClient.create("redis://localhost:6379"); + + try (StatefulRedisConnection connection = redisClient.connect()) { + RedisAsyncCommands asyncCommands = connection.async(); + + // STEP_START set_get + CompletableFuture setAndGet = asyncCommands.set("bike:1", "Deimos").thenCompose(v -> { + System.out.println(v); // >>> OK + // REMOVE_START + assertThat(v).isEqualTo("OK"); + // REMOVE_END + return asyncCommands.get("bike:1"); + }) + // REMOVE_START + .thenApply(res -> { + assertThat(res).isEqualTo("Deimos"); + return res; + }) + // REMOVE_END + .thenAccept(System.out::println) // >>> Deimos + .toCompletableFuture(); + // STEP_END + // HIDE_START + setAndGet.join(); + // HIDE_END + + // STEP_START setnx_xx + // Recreate the bike:1 key so this example runs on its own. + CompletableFuture setnx = asyncCommands.set("bike:1", "Deimos") + .thenCompose(setup -> asyncCommands.setnx("bike:1", "bike")).thenCompose(v -> { + System.out.println(v); // >>> false (because key already exists) + // REMOVE_START + assertThat(v).isFalse(); + // REMOVE_END + return asyncCommands.get("bike:1"); + }) + // REMOVE_START + .thenApply(res -> { + assertThat(res).isEqualTo("Deimos"); + return res; + }) + // REMOVE_END + .thenAccept(System.out::println) // >>> Deimos (value is unchanged) + .toCompletableFuture(); + // HIDE_START + setnx.join(); + // HIDE_END + + // set the value to "bike" if it already exists + CompletableFuture setxx = asyncCommands.set("bike:1", "bike", SetArgs.Builder.xx()) + // REMOVE_START + .thenApply(res -> { + assertThat(res).isEqualTo("OK"); + return res; + }) + // REMOVE_END + .thenAccept(System.out::println) // >>> OK + .toCompletableFuture(); + // HIDE_START + setxx.join(); + // HIDE_END + // STEP_END + + // STEP_START mset + Map bikeMap = new HashMap<>(); + bikeMap.put("bike:1", "Deimos"); + bikeMap.put("bike:2", "Ares"); + bikeMap.put("bike:3", "Vanth"); + + CompletableFuture mset = asyncCommands.mset(bikeMap).thenCompose(v -> { + System.out.println(v); // >>> OK + return asyncCommands.mget("bike:1", "bike:2", "bike:3"); + }) + // REMOVE_START + .thenApply(res -> { + List> expected = new ArrayList<>( + Arrays.asList(KeyValue.just("bike:1", "Deimos"), KeyValue.just("bike:2", "Ares"), + KeyValue.just("bike:3", "Vanth"))); + assertThat(res).isEqualTo(expected); + return res; + }) + // REMOVE_END + .thenAccept(System.out::println) + // >>> [KeyValue[bike:1, Deimos], KeyValue[bike:2, Ares], KeyValue[bike:3, + // Vanth]] + .toCompletableFuture(); + // STEP_END + // HIDE_START + mset.join(); + // HIDE_END + + // STEP_START incr + CompletableFuture incrby = asyncCommands.set("total_crashes", "0") + .thenCompose(v -> asyncCommands.incr("total_crashes")).thenCompose(v -> { + System.out.println(v); // >>> 1 + // REMOVE_START + assertThat(v).isEqualTo(1L); + // REMOVE_END + return asyncCommands.incrby("total_crashes", 10); + }) + // REMOVE_START + .thenApply(res -> { + assertThat(res).isEqualTo(11L); + return res; + }) + // REMOVE_END + .thenAccept(System.out::println) // >>> 11 + .toCompletableFuture(); + // STEP_END + // HIDE_START + incrby.join(); + // HIDE_END + } finally { + redisClient.shutdown(); + } + } + +} diff --git a/local_examples/client-specific/lettuce-reactive/StringExample.java b/local_examples/client-specific/lettuce-reactive/StringExample.java new file mode 100644 index 0000000000..0401a61898 --- /dev/null +++ b/local_examples/client-specific/lettuce-reactive/StringExample.java @@ -0,0 +1,107 @@ +// EXAMPLE: set_tutorial +package io.redis.examples.reactive; + +import io.lettuce.core.*; +import io.lettuce.core.api.reactive.RedisReactiveCommands; +import io.lettuce.core.api.StatefulRedisConnection; +// REMOVE_START +import org.junit.jupiter.api.Test; +// REMOVE_END +import reactor.core.publisher.Mono; + +import java.util.*; + +// REMOVE_START +import static org.assertj.core.api.Assertions.assertThat; +// REMOVE_END + +public class StringExample { + + // REMOVE_START + @Test + // REMOVE_END + public void run() { + RedisClient redisClient = RedisClient.create("redis://localhost:6379"); + + try (StatefulRedisConnection connection = redisClient.connect()) { + RedisReactiveCommands reactiveCommands = connection.reactive(); + + // STEP_START set_get + Mono setAndGet = reactiveCommands.set("bike:1", "Deimos").doOnNext(v -> { + System.out.println(v); // OK + // REMOVE_START + assertThat(v).isEqualTo("OK"); + // REMOVE_END + }).flatMap(v -> reactiveCommands.get("bike:1")).doOnNext(res -> { + // REMOVE_START + assertThat(res).isEqualTo("Deimos"); + // REMOVE_END + System.out.println(res); // Deimos + }).then(); + // STEP_END + + // STEP_START setnx_xx + // Recreate the bike:1 key so this example runs on its own. + Mono setnx = reactiveCommands.set("bike:1", "Deimos") + .flatMap(setup -> reactiveCommands.setnx("bike:1", "bike")).doOnNext(v -> { + System.out.println(v); // false (because key already exists) + // REMOVE_START + assertThat(v).isFalse(); + // REMOVE_END + }).flatMap(v -> reactiveCommands.get("bike:1")).doOnNext(res -> { + // REMOVE_START + assertThat(res).isEqualTo("Deimos"); + // REMOVE_END + System.out.println(res); // Deimos (value is unchanged) + }).then(); + + Mono setxx = reactiveCommands.set("bike:1", "bike", SetArgs.Builder.xx()).doOnNext(res -> { + // REMOVE_START + assertThat(res).isEqualTo("OK"); + // REMOVE_END + System.out.println(res); // OK + }).then(); + // STEP_END + + // STEP_START mset + Map bikeMap = new HashMap<>(); + bikeMap.put("bike:1", "Deimos"); + bikeMap.put("bike:2", "Ares"); + bikeMap.put("bike:3", "Vanth"); + + Mono mset = reactiveCommands.mset(bikeMap).doOnNext(System.out::println) // OK + .flatMap(v -> reactiveCommands.mget("bike:1", "bike:2", "bike:3").collectList()).doOnNext(res -> { + // REMOVE_START + List> expected = new ArrayList<>( + Arrays.asList(KeyValue.just("bike:1", "Deimos"), KeyValue.just("bike:2", "Ares"), + KeyValue.just("bike:3", "Vanth"))); + + assertThat(res).isEqualTo(expected); + // REMOVE_END + System.out.println(res); // [KeyValue[bike:1, Deimos], KeyValue[bike:2, Ares], KeyValue[bike:3, Vanth]] + }).then(); + // STEP_END + + // STEP_START incr + Mono incrby = reactiveCommands.set("total_crashes", "0").flatMap(v -> reactiveCommands.incr("total_crashes")) + .doOnNext(v -> { + System.out.println(v); // 1 + // REMOVE_START + assertThat(v).isEqualTo(1L); + // REMOVE_END + }).flatMap(v -> reactiveCommands.incrby("total_crashes", 10)).doOnNext(res -> { + // REMOVE_START + assertThat(res).isEqualTo(11L); + // REMOVE_END + System.out.println(res); // 11 + }).then(); + // STEP_END + + Mono.when(setAndGet, setnx, setxx, mset, incrby).block(); + + } finally { + redisClient.shutdown(); + } + } + +} diff --git a/local_examples/php/DtStringTest.php b/local_examples/php/DtStringTest.php index 6ea1420a7e..f423d7eceb 100644 --- a/local_examples/php/DtStringTest.php +++ b/local_examples/php/DtStringTest.php @@ -38,6 +38,9 @@ public function testDtString() { // REMOVE_END // STEP_START setnx_xx + // Recreate the bike:1 key so this example runs on its own. + $r->set('bike:1', 'Deimos'); + $res3 = $r->set('bike:1', 'bike', 'nx'); echo "$res3" . PHP_EOL; // >>> (null) diff --git a/local_examples/ruby/dt_string.rb b/local_examples/ruby/dt_string.rb index c7adaf0aaa..98898897a7 100644 --- a/local_examples/ruby/dt_string.rb +++ b/local_examples/ruby/dt_string.rb @@ -19,6 +19,9 @@ # REMOVE_END # STEP_START setnx_xx +# Recreate the bike:1 key so this example runs on its own. +r.set('bike:1', 'Deimos') + res3 = r.set('bike:1', 'bike', nx: true) puts res3 # false diff --git a/local_examples/rust-async/dt-string.rs b/local_examples/rust-async/dt-string.rs index 40535b3399..bb64639851 100644 --- a/local_examples/rust-async/dt-string.rs +++ b/local_examples/rust-async/dt-string.rs @@ -46,6 +46,9 @@ mod tests { // STEP_END // STEP_START setnx_xx + // Recreate the bike:1 key so this example runs on its own. + let _: () = r.set("bike:1", "Deimos").await.expect("Failed to set"); + if let Ok(res) = r.set_options("bike:1", "bike", redis::SetOptions::default().conditional_set(ExistenceCheck::NX)).await { let res: bool = res; println!("{res}"); // >>> false diff --git a/local_examples/rust-sync/dt-string.rs b/local_examples/rust-sync/dt-string.rs index 46f4ac8da7..74fc654c8f 100644 --- a/local_examples/rust-sync/dt-string.rs +++ b/local_examples/rust-sync/dt-string.rs @@ -48,6 +48,9 @@ mod strings_tests { // STEP_END // STEP_START setnx_xx + // Recreate the bike:1 key so this example runs on its own. + let _: () = r.set("bike:1", "Deimos").expect("Failed to set"); + if let Ok(res) = r.set_options("bike:1", "bike", redis::SetOptions::default().conditional_set(ExistenceCheck::NX)) { let res: bool = res; println!("{res}"); // >>> false diff --git a/local_examples/tmp/datatypes/strings/StringExample.java b/local_examples/tmp/datatypes/strings/StringExample.java index 1cd2982255..9038cc31ee 100644 --- a/local_examples/tmp/datatypes/strings/StringExample.java +++ b/local_examples/tmp/datatypes/strings/StringExample.java @@ -34,6 +34,9 @@ public void run() { // REMOVE_END // STEP_START setnx_xx + // Recreate the bike:1 key so this example runs on its own. + jedis.set("bike:1", "Deimos"); + Long res3 = jedis.setnx("bike:1", "bike"); System.out.println(res3); // 0 (because key already exists) System.out.println(jedis.get("bike:1")); // Deimos (value is unchanged) diff --git a/local_examples/tmp/datatypes/strings/StringSnippets.cs b/local_examples/tmp/datatypes/strings/StringSnippets.cs index 2e3d79ab7a..6d52b61d6b 100644 --- a/local_examples/tmp/datatypes/strings/StringSnippets.cs +++ b/local_examples/tmp/datatypes/strings/StringSnippets.cs @@ -49,6 +49,9 @@ public void Run() //REMOVE_END //STEP_START setnx_xx + // Recreate the bike:1 key so this example runs on its own. + db.StringSet("bike:1", "Deimos"); + var res3 = db.StringSet("bike:1", "bike", when: When.NotExists); Console.WriteLine(res3); // false Console.WriteLine(db.StringGet("bike:1")); diff --git a/local_examples/tmp/datatypes/strings/dt-string.js b/local_examples/tmp/datatypes/strings/dt-string.js index 3d9ab300ec..3f96ce2d33 100644 --- a/local_examples/tmp/datatypes/strings/dt-string.js +++ b/local_examples/tmp/datatypes/strings/dt-string.js @@ -24,6 +24,9 @@ assert.equal(res2, 'Deimos'); // REMOVE_END // STEP_START setnx_xx +// Recreate the bike:1 key so this example runs on its own. +await client.set("bike:1", "Deimos"); + const res3 = await client.set("bike:1", "bike", {'NX': true}); console.log(res3); // null console.log(await client.get("bike:1")); // Deimos diff --git a/local_examples/tmp/datatypes/strings/dt_string.py b/local_examples/tmp/datatypes/strings/dt_string.py index 7ca21fda5b..cbb2092d69 100644 --- a/local_examples/tmp/datatypes/strings/dt_string.py +++ b/local_examples/tmp/datatypes/strings/dt_string.py @@ -24,6 +24,9 @@ # REMOVE_END # STEP_START setnx_xx +# Recreate the bike:1 key so this example runs on its own. +r.set("bike:1", "Deimos") + res3 = r.set("bike:1", "bike", nx=True) print(res3) # None print(r.get("bike:1")) # Deimos diff --git a/local_examples/tmp/datatypes/strings/string_example_test.go b/local_examples/tmp/datatypes/strings/string_example_test.go index b3765478a3..5df48bdd63 100644 --- a/local_examples/tmp/datatypes/strings/string_example_test.go +++ b/local_examples/tmp/datatypes/strings/string_example_test.go @@ -61,10 +61,12 @@ func ExampleClient_setnx_xx() { // REMOVE_START // start with fresh database rdb.FlushDB(ctx) - rdb.Set(ctx, "bike:1", "Deimos", 0) // REMOVE_END // STEP_START setnx_xx + // Recreate the bike:1 key so this example runs on its own. + rdb.Set(ctx, "bike:1", "Deimos", 0) + res3, err := rdb.SetNX(ctx, "bike:1", "bike", 0).Result() if err != nil { From 329abaef778e6aa11e354461897df6961371526f Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Wed, 8 Jul 2026 14:31:59 +0100 Subject: [PATCH 03/28] DOC-6823 add reusable multi-client example test harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds build/example-test-harness: given an example set, it runs each client's local_examples source file against a live throwaway Redis on localhost:6379, using each library's real in-file assertions, and prints a pass/fail matrix. Covers all 11 client tabs (python, node, go, jedis, lettuce async+reactive, c#, php, ruby, rust sync+async). Built to verify the DOC-6823 self-containment edits and reusable for the rest of the try_it="false" backlog — adding an example set is one src_path() case block. Dev-only tooling, not part of the Hugo build; cached projects (work/) and logs (results/) are gitignored. Toolchain versions and the trickier setup notes live in the README. Constraint: the harness FLUSHes before each run — point it only at a scratch Redis Learned: redis-rs is now 1.x (flushall is absent in 0.27); Surefire's default pattern skips *Example classes so the POMs add an explicit include; C# and PHP examples need their repo's test base classes stubbed Directive: add a src_path() case block per new example set Ticket: DOC-6823 Co-Authored-By: Claude Opus 4.8 (1M context) --- build/example-test-harness/.gitignore | 3 + build/example-test-harness/README.md | 52 ++++++ .../dotnet/GlobalUsings.cs | 1 + .../dotnet/harness.csproj | 15 ++ build/example-test-harness/dotnet/stubs.cs | 32 ++++ build/example-test-harness/pom-jedis.xml | 15 ++ .../pom-lettuce-async.xml | 15 ++ .../pom-lettuce-reactive.xml | 15 ++ build/example-test-harness/run.sh | 152 ++++++++++++++++++ 9 files changed, 300 insertions(+) create mode 100644 build/example-test-harness/.gitignore create mode 100644 build/example-test-harness/README.md create mode 100644 build/example-test-harness/dotnet/GlobalUsings.cs create mode 100644 build/example-test-harness/dotnet/harness.csproj create mode 100644 build/example-test-harness/dotnet/stubs.cs create mode 100644 build/example-test-harness/pom-jedis.xml create mode 100644 build/example-test-harness/pom-lettuce-async.xml create mode 100644 build/example-test-harness/pom-lettuce-reactive.xml create mode 100755 build/example-test-harness/run.sh diff --git a/build/example-test-harness/.gitignore b/build/example-test-harness/.gitignore new file mode 100644 index 0000000000..1e8d39635c --- /dev/null +++ b/build/example-test-harness/.gitignore @@ -0,0 +1,3 @@ +# transient: cached per-language projects/deps and run logs +work/ +results/ diff --git a/build/example-test-harness/README.md b/build/example-test-harness/README.md new file mode 100644 index 0000000000..ec70e54ca1 --- /dev/null +++ b/build/example-test-harness/README.md @@ -0,0 +1,52 @@ +# TCE example test harness + +Runs a docs example set's per-client source files (from `local_examples/`) against a +**live throwaway Redis on `localhost:6379`**, using each library's real in-file +assertions. Built for the DOC-6823 work (making `try_it="false"` examples self-contained), +but reusable for any example set. + +## Usage + +```bash +./run.sh [client ...] +# all clients: +./run.sh ss_tutorial +# one/some: +./run.sh set_tutorial rust-sync dotnet +``` + +`example_set` ∈ { `ss_tutorial`, `set_tutorial` } today. Results print as a matrix; +per-run logs land in `results/_.log`. + +⚠️ Several examples call `FLUSHALL`/`FLUSHDB`, and the harness flushes before each run. +Point it only at a scratch Redis. + +## Clients & how each is run + +| Client | Toolchain | Notes | +|---|---|---| +| python | venv + `redis` | run script directly (`assert`) | +| node | `npm i redis`, ESM | run as `.mjs` | +| go | module + `go-redis` | `go test`; needs a sibling `package example_commands` stub | +| jedis | Maven + `jedis:5.2.0` | surefire include `**/*Example.java` (classes aren't `*Test`) | +| lettuce-async / -reactive | Maven + `lettuce-core:6.5.5.RELEASE` | same surefire include | +| ruby | `redis` gem | run script (`raise`/local `assert_equal`) | +| rust-sync | Cargo + `redis = "1.3"` | file is `#[cfg(test)]` → dropped in `src/lib.rs`, `cargo test` | +| rust-async | Cargo + `redis` (tokio-comp) + `tokio` | `#[tokio::test]` | +| php | Composer + `predis/predis` | `bootstrap.php` stubs `PredisTestCase` asserts; reflection finds the `test*` method | +| dotnet | `dotnet test` + xunit + `StackExchange.Redis` | `dotnet/stubs.cs` stands in for NRedisStack's `AbstractNRedisStackTest`/`EndpointsFixture`/`[SkippableFact]`/`DocsTests` collection | + +## Gotchas learned + +- **Version pins matter.** `redis-rs` is now **1.x** (`1.3`), not `0.27` — the old pin failed + to compile `flushall` in a REMOVE block. Jedis 5.2, Lettuce 6.5.5, StackExchange.Redis 2.8.x. +- **Surefire only runs `*Test`/`*Tests` by default** — these classes are `*Example`, so the + POMs add an explicit ``. First run looked green with **zero tests** without it. +- The test scaffolding lives in `REMOVE_START` blocks; for py/ruby/node/go/rust/jedis/lettuce + it's self-contained (stdlib asserts / JUnit), but **C# and PHP reference their repo's own + test base classes**, which is why they need the stubs above. + +## Adding a new example set + +Add one block of `case "$set:$client"` → source-path lines in `src_path()` in `run.sh`. +Nothing else changes; deps are cached under `work/`. diff --git a/build/example-test-harness/dotnet/GlobalUsings.cs b/build/example-test-harness/dotnet/GlobalUsings.cs new file mode 100644 index 0000000000..c802f4480b --- /dev/null +++ b/build/example-test-harness/dotnet/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/build/example-test-harness/dotnet/harness.csproj b/build/example-test-harness/dotnet/harness.csproj new file mode 100644 index 0000000000..38467f7290 --- /dev/null +++ b/build/example-test-harness/dotnet/harness.csproj @@ -0,0 +1,15 @@ + + + net9.0 + latest + disable + enable + false + + + + + + + + diff --git a/build/example-test-harness/dotnet/stubs.cs b/build/example-test-harness/dotnet/stubs.cs new file mode 100644 index 0000000000..a8d59f6f30 --- /dev/null +++ b/build/example-test-harness/dotnet/stubs.cs @@ -0,0 +1,32 @@ +// Minimal stand-ins for NRedisStack's own test fixtures, so a docs example +// file (which extends AbstractNRedisStackTest and uses [SkippableFact]) runs +// under plain xunit + StackExchange.Redis. The example creates its own +// ConnectionMultiplexer, so the fixture helpers can be no-ops. +using StackExchange.Redis; + +namespace NRedisStack.Tests +{ + public class EndpointsFixture + { + public enum Env { Standalone } + } + + public class AbstractNRedisStackTest : IDisposable + { + protected AbstractNRedisStackTest(EndpointsFixture fixture) { } + protected void SkipIfTargetConnectionDoesNotExist(EndpointsFixture.Env env) { } + protected IDatabase GetCleanDatabase(EndpointsFixture.Env env) + { + var muxer = ConnectionMultiplexer.Connect("localhost:6379"); + return muxer.GetDatabase(); + } + public void Dispose() { } + } + + // The examples annotate the test method [SkippableFact]; make it a plain Fact. + public class SkippableFactAttribute : FactAttribute { } +} + +// Satisfy [Collection("DocsTests")] + constructor injection of EndpointsFixture. +[CollectionDefinition("DocsTests")] +public class DocsTestsCollection : ICollectionFixture { } diff --git a/build/example-test-harness/pom-jedis.xml b/build/example-test-harness/pom-jedis.xml new file mode 100644 index 0000000000..2df33293e0 --- /dev/null +++ b/build/example-test-harness/pom-jedis.xml @@ -0,0 +1,15 @@ + + 4.0.0 + io.redisjedis1.0 + 17UTF-8 + + redis.clientsjedis5.2.0 + org.junit.jupiterjunit-jupiter5.10.2test + org.assertjassertj-core3.25.3test + + + org.apache.maven.pluginsmaven-surefire-plugin3.2.5 + **/*Example.java + + diff --git a/build/example-test-harness/pom-lettuce-async.xml b/build/example-test-harness/pom-lettuce-async.xml new file mode 100644 index 0000000000..b488bebbf5 --- /dev/null +++ b/build/example-test-harness/pom-lettuce-async.xml @@ -0,0 +1,15 @@ + + 4.0.0 + io.redislettuce-async1.0 + 17UTF-8 + + io.lettucelettuce-core6.5.5.RELEASE + org.junit.jupiterjunit-jupiter5.10.2test + org.assertjassertj-core3.25.3test + + + org.apache.maven.pluginsmaven-surefire-plugin3.2.5 + **/*Example.java + + diff --git a/build/example-test-harness/pom-lettuce-reactive.xml b/build/example-test-harness/pom-lettuce-reactive.xml new file mode 100644 index 0000000000..e3f67e7892 --- /dev/null +++ b/build/example-test-harness/pom-lettuce-reactive.xml @@ -0,0 +1,15 @@ + + 4.0.0 + io.redislettuce-reactive1.0 + 17UTF-8 + + io.lettucelettuce-core6.5.5.RELEASE + org.junit.jupiterjunit-jupiter5.10.2test + org.assertjassertj-core3.25.3test + + + org.apache.maven.pluginsmaven-surefire-plugin3.2.5 + **/*Example.java + + diff --git a/build/example-test-harness/run.sh b/build/example-test-harness/run.sh new file mode 100755 index 0000000000..2cc79c4d12 --- /dev/null +++ b/build/example-test-harness/run.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash +# Reusable TCE example test harness. +# Runs a docs example set's client source files against a live (throwaway) Redis +# on localhost:6379, using each library's real in-file assertions. +# +# Usage: ./run.sh [client ...] +# example_set : ss_tutorial | set_tutorial (add more in src_path()) +# client : one or more of the CLIENTS below; default = all +# +# Assumes a SCRATCH Redis on 6379 — several examples FLUSH the db. +set -uo pipefail + +REPO="$(cd "$(dirname "$0")/../.." && pwd)" +HARNESS="$(cd "$(dirname "$0")" && pwd)" +WORK="$HARNESS/work"; mkdir -p "$WORK" +SET="${1:?usage: run.sh [client...]}"; shift || true + +CLIENTS_ALL=(python node go jedis ruby rust-sync rust-async lettuce-async lettuce-reactive php dotnet) +CLIENTS=("$@"); [ ${#CLIENTS[@]} -eq 0 ] && CLIENTS=("${CLIENTS_ALL[@]}") + +# --- example_set + client -> repo-relative source path ----------------------- +src_path() { + local set="$1" client="$2" + case "$set:$client" in + ss_tutorial:python) echo local_examples/tmp/datatypes/sorted-sets/dt_ss.py ;; + ss_tutorial:node) echo local_examples/tmp/datatypes/sorted-sets/dt-ss.js ;; + ss_tutorial:go) echo local_examples/tmp/datatypes/sorted-sets/ss_tutorial_test.go ;; + ss_tutorial:jedis) echo local_examples/tmp/datatypes/sorted-sets/SortedSetsExample.java ;; + ss_tutorial:ruby) echo local_examples/ruby/dt_sorted_sets.rb ;; + ss_tutorial:rust-sync) echo local_examples/rust-sync/dt-sorted-sets.rs ;; + ss_tutorial:rust-async) echo local_examples/rust-async/dt-sorted-sets.rs ;; + ss_tutorial:lettuce-async) echo local_examples/client-specific/lettuce-async/SortedSetExample.java ;; + ss_tutorial:lettuce-reactive)echo local_examples/client-specific/lettuce-reactive/SortedSetExample.java ;; + ss_tutorial:php) echo local_examples/php/DtSortedSetsTest.php ;; + ss_tutorial:dotnet) echo local_examples/tmp/datatypes/sorted-sets/SortedSetExample.cs ;; + set_tutorial:python) echo local_examples/tmp/datatypes/strings/dt_string.py ;; + set_tutorial:node) echo local_examples/tmp/datatypes/strings/dt-string.js ;; + set_tutorial:go) echo local_examples/tmp/datatypes/strings/string_example_test.go ;; + set_tutorial:jedis) echo local_examples/tmp/datatypes/strings/StringExample.java ;; + set_tutorial:ruby) echo local_examples/ruby/dt_string.rb ;; + set_tutorial:rust-sync) echo local_examples/rust-sync/dt-string.rs ;; + set_tutorial:rust-async) echo local_examples/rust-async/dt-string.rs ;; + set_tutorial:lettuce-async) echo local_examples/client-specific/lettuce-async/StringExample.java ;; + set_tutorial:lettuce-reactive)echo local_examples/client-specific/lettuce-reactive/StringExample.java ;; + set_tutorial:php) echo local_examples/php/DtStringTest.php ;; + set_tutorial:dotnet) echo local_examples/tmp/datatypes/strings/StringSnippets.cs ;; + *) echo "" ;; + esac +} + +log() { printf '%s\n' "$*"; } +SUMMARY=() # entries: "clientstatus" (bash 3.2 compatible) + +# --- per-language runners: set $rc (0=pass) and leave detail in $LOG ---------- +run_python() { + [ -d "$WORK/py/venv" ] || { python3 -m venv "$WORK/py/venv"; "$WORK/py/venv/bin/pip" -q install redis; } + "$WORK/py/venv/bin/python" "$1" >"$LOG" 2>&1; rc=$? +} +run_ruby() { + gem list -i redis >/dev/null 2>&1 || gem install --silent redis >/dev/null 2>&1 + ruby "$1" >"$LOG" 2>&1; rc=$? +} +run_node() { + local d="$WORK/node"; mkdir -p "$d" + [ -d "$d/node_modules/redis" ] || { printf '{"type":"module"}\n' >"$d/package.json"; (cd "$d" && npm i -s redis >/dev/null 2>&1); } + cp "$1" "$d/example.mjs"; (cd "$d" && node example.mjs) >"$LOG" 2>&1; rc=$? +} +run_go() { + local d="$WORK/go"; mkdir -p "$d" + if [ ! -f "$d/go.mod" ]; then + (cd "$d" && go mod init tce.local >/dev/null 2>&1 && printf 'package example_commands\n' >lib.go \ + && go get github.com/redis/go-redis/v9 >/dev/null 2>&1) + fi + cp "$1" "$d/ex_test.go"; (cd "$d" && go test ./... ) >"$LOG" 2>&1; rc=$? +} +run_rust_sync() { rust_run "$WORK/rust-sync" "$1" 'redis = "1.3"' ; } +run_rust_async(){ rust_run "$WORK/rust-async" "$1" 'redis = { version = "1.3", features = ["tokio-comp"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread"] }' ; } +rust_run() { + local d="$1" src="$2" deps="$3"; mkdir -p "$d/src" + if [ ! -f "$d/Cargo.toml" ]; then + cat >"$d/Cargo.toml" <"$LOG" 2>&1; rc=$? +} +run_maven_java() { # $1=src $2=workdir $3=package-relpath + local d="$2"; mkdir -p "$d/src/test/java/$3" + [ -f "$d/pom.xml" ] || cp "$HARNESS/pom-$(basename "$d").xml" "$d/pom.xml" + rm -f "$d/src/test/java/$3"/*.java + cp "$1" "$d/src/test/java/$3/" + (cd "$d" && mvn -q -B test) >"$LOG" 2>&1; rc=$? +} +run_jedis() { run_maven_java "$1" "$WORK/jedis" io/redis/examples ; } +run_lettuce_async() { run_maven_java "$1" "$WORK/lettuce-async" io/redis/examples/async ; } +run_lettuce_reactive() { run_maven_java "$1" "$WORK/lettuce-reactive" io/redis/examples/reactive ; } +run_dotnet() { # stubs NRedisStack.Tests fixtures so the file runs under plain xunit + local d="$WORK/dotnet"; mkdir -p "$d" + cp "$HARNESS/dotnet/harness.csproj" "$d/harness.csproj" + cp "$HARNESS/dotnet/stubs.cs" "$d/stubs.cs" + cp "$HARNESS/dotnet/GlobalUsings.cs" "$d/GlobalUsings.cs" + rm -f "$d"/Example_*.cs; cp "$1" "$d/Example_src.cs" + (cd "$d" && dotnet test --nologo) >"$LOG" 2>&1; rc=$? +} +run_php() { + local d="$WORK/php"; mkdir -p "$d" + [ -d "$d/vendor/predis" ] || { printf '{}\n' >"$d/composer.json"; (cd "$d" && composer -q require predis/predis >/dev/null 2>&1); } + cat >"$d/bootstrap.php" <<'PHP' +$m(); } } + fwrite(STDERR,"OK\n"); + ') >"$LOG" 2>&1; rc=$? +} + +# --- drive -------------------------------------------------------------------- +log "=== TCE sweep: $SET (redis @ localhost:6379) ===" +mkdir -p "$HARNESS/results" +for c in "${CLIENTS[@]}"; do + rel="$(src_path "$SET" "$c")" + if [ -z "$rel" ] || [ ! -f "$REPO/$rel" ]; then SUMMARY+=("$c SKIP (no source)"); log ">> $c: SKIP"; continue; fi + LOG="$HARNESS/results/${SET}_${c}.log"; rc=1 + redis-cli flushall >/dev/null 2>&1 + log ">> $c: running..." + "run_${c//-/_}" "$REPO/$rel" + if [ "${rc:-1}" -eq 0 ]; then SUMMARY+=("$c PASS"); log ">> $c: PASS" + else SUMMARY+=("$c FAIL (results/${SET}_${c}.log)"); log ">> $c: FAIL"; fi +done + +log ""; log "=== RESULTS: $SET ===" +for e in "${SUMMARY[@]}"; do printf ' %-18s %s\n' "${e%% *}" "${e#* }"; done From c85873350c93f99e4a263c598c3550db6fe6bf30 Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Wed, 8 Jul 2026 14:58:52 +0100 Subject: [PATCH 04/28] DOC-6823 make hash hmget/hincrby examples runnable in the interactive CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes try_it="false" from the hmget and hincrby examples and prepends a recreate (DEL bike:1 + HSET of the full bike object) so each runs standalone. Unlike zadd_lex (where only a count differed), the cold-sandbox output here was entirely wrong — hmget returned all nils and hincrby returned 100 instead of 5072 — so the recreate is required, not cosmetic. Adds the missing Lettuce async HashExample override (a reactive one already existed locally). Wires hash_tutorial into build/example-test-harness. The Lettuce reactive file needed care: its hmget Mono shared a single Mono.when(...) with the set_get_all reads, so a DEL-based recreate inside that group would race the concurrent hget/hgetall on the same key. Pulled hmget out to block sequentially after the reads. All 11 client examples for this set were run against a live Redis via the harness and pass. Learned: a DEL-based recreate inside a reactive Mono.when races sibling reads on the same key — sequence it, don't group it Directive: the new Lettuce async HashExample override duplicates upstream — push it to redis/lettuce so their CI covers it Ticket: DOC-6823 Co-Authored-By: Claude Opus 4.8 (1M context) --- build/example-test-harness/run.sh | 11 ++ content/develop/data-types/hashes.md | 14 +- local_examples/php/DtHashTest.php | 18 ++ local_examples/ruby/dt_hash.rb | 18 ++ local_examples/rust-async/dt-hash.rs | 14 ++ local_examples/rust-sync/dt-hash.rs | 14 ++ .../tmp/datatypes/hashes/HashExample.cs | 18 ++ .../tmp/datatypes/hashes/HashExample.java | 8 + .../tmp/datatypes/hashes/dt-hash.js | 24 +++ .../tmp/datatypes/hashes/dt_hash.py | 24 +++ .../datatypes/hashes/hash_tutorial_test.go | 24 +-- .../tmp/lettuce-async/HashExample.java | 171 ++++++++++++++++++ .../tmp/lettuce-reactive/HashExample.java | 14 +- 13 files changed, 351 insertions(+), 21 deletions(-) create mode 100644 local_examples/tmp/lettuce-async/HashExample.java diff --git a/build/example-test-harness/run.sh b/build/example-test-harness/run.sh index 2cc79c4d12..6b8cbf5fd4 100755 --- a/build/example-test-harness/run.sh +++ b/build/example-test-harness/run.sh @@ -44,6 +44,17 @@ src_path() { set_tutorial:lettuce-reactive)echo local_examples/client-specific/lettuce-reactive/StringExample.java ;; set_tutorial:php) echo local_examples/php/DtStringTest.php ;; set_tutorial:dotnet) echo local_examples/tmp/datatypes/strings/StringSnippets.cs ;; + hash_tutorial:python) echo local_examples/tmp/datatypes/hashes/dt_hash.py ;; + hash_tutorial:node) echo local_examples/tmp/datatypes/hashes/dt-hash.js ;; + hash_tutorial:go) echo local_examples/tmp/datatypes/hashes/hash_tutorial_test.go ;; + hash_tutorial:jedis) echo local_examples/tmp/datatypes/hashes/HashExample.java ;; + hash_tutorial:ruby) echo local_examples/ruby/dt_hash.rb ;; + hash_tutorial:rust-sync) echo local_examples/rust-sync/dt-hash.rs ;; + hash_tutorial:rust-async) echo local_examples/rust-async/dt-hash.rs ;; + hash_tutorial:lettuce-async) echo local_examples/tmp/lettuce-async/HashExample.java ;; + hash_tutorial:lettuce-reactive)echo local_examples/tmp/lettuce-reactive/HashExample.java ;; + hash_tutorial:php) echo local_examples/php/DtHashTest.php ;; + hash_tutorial:dotnet) echo local_examples/tmp/datatypes/hashes/HashExample.cs ;; *) echo "" ;; esac } diff --git a/content/develop/data-types/hashes.md b/content/develop/data-types/hashes.md index 3d01966ffc..7258d7467a 100644 --- a/content/develop/data-types/hashes.md +++ b/content/develop/data-types/hashes.md @@ -52,7 +52,12 @@ hashes in many different ways inside your application. The command [`HSET`]({{< relref "/commands/hset" >}}) sets multiple fields of the hash, while [`HGET`]({{< relref "/commands/hget" >}}) retrieves a single field. [`HMGET`]({{< relref "/commands/hmget" >}}) is similar to [`HGET`]({{< relref "/commands/hget" >}}) but returns an array of values: -{{< clients-example set="hash_tutorial" step="hmget" description="Retrieve multiple field values from a hash using HMGET when you need to reduce round trips to the server" buildsUpon="set_get_all" try_it="false" >}} +{{< clients-example set="hash_tutorial" step="hmget" description="Retrieve multiple field values from a hash using HMGET when you need to reduce round trips to the server" buildsUpon="set_get_all" >}} +# Recreate the bike:1 hash so this example runs on its own. +> DEL bike:1 +(integer) 1 +> HSET bike:1 model Deimos brand Ergonom type 'Enduro bikes' price 4972 +(integer) 4 > HMGET bike:1 model price no-such-field 1) "Deimos" 2) "4972" @@ -62,7 +67,12 @@ a single field. [`HMGET`]({{< relref "/commands/hmget" >}}) is similar to [`HGET There are commands that are able to perform operations on individual fields as well, like [`HINCRBY`]({{< relref "/commands/hincrby" >}}): -{{< clients-example set="hash_tutorial" step="hincrby" description="Increment hash field values for counters using HINCRBY (creates field if missing, initializes to 0)" buildsUpon="set_get_all" try_it="false" >}} +{{< clients-example set="hash_tutorial" step="hincrby" description="Increment hash field values for counters using HINCRBY (creates field if missing, initializes to 0)" buildsUpon="set_get_all" >}} +# Recreate the bike:1 hash so this example runs on its own. +> DEL bike:1 +(integer) 1 +> HSET bike:1 model Deimos brand Ergonom type 'Enduro bikes' price 4972 +(integer) 4 > HINCRBY bike:1 price 100 (integer) 5072 > HINCRBY bike:1 price -100 diff --git a/local_examples/php/DtHashTest.php b/local_examples/php/DtHashTest.php index eadeefb1c8..b5eded71fb 100644 --- a/local_examples/php/DtHashTest.php +++ b/local_examples/php/DtHashTest.php @@ -57,6 +57,15 @@ public function testDtHash() { // REMOVE_END // STEP_START hmget + // Recreate the bike:1 hash so this example runs on its own. + $r->del('bike:1'); + $r->hset('bike:1', [ + 'model' => 'Deimos', + 'brand' => 'Ergonom', + 'type' => 'Enduro bikes', + 'price' => 4972, + ]); + $res5 = $r->hmget('bike:1', ['model', 'price']); echo json_encode($res5) . PHP_EOL; // >>> ["Deimos","4972"] @@ -66,6 +75,15 @@ public function testDtHash() { // REMOVE_END // STEP_START hincrby + // Recreate the bike:1 hash so this example runs on its own. + $r->del('bike:1'); + $r->hset('bike:1', [ + 'model' => 'Deimos', + 'brand' => 'Ergonom', + 'type' => 'Enduro bikes', + 'price' => 4972, + ]); + $res6 = $r->hincrby('bike:1', 'price', 100); echo $res6 . PHP_EOL; // >>> 5072 diff --git a/local_examples/ruby/dt_hash.rb b/local_examples/ruby/dt_hash.rb index da31462727..898044de23 100644 --- a/local_examples/ruby/dt_hash.rb +++ b/local_examples/ruby/dt_hash.rb @@ -46,6 +46,15 @@ def assert_equal(expected, actual) # REMOVE_END # STEP_START hmget +# Recreate the bike:1 hash so this example runs on its own. +r.del('bike:1') +r.hset('bike:1', { + 'model' => 'Deimos', + 'brand' => 'Ergonom', + 'type' => 'Enduro bikes', + 'price' => 4972 +}) + res5 = r.hmget('bike:1', 'model', 'price', 'no-such-field') puts res5.inspect # ["Deimos", "4972", nil] # STEP_END @@ -55,6 +64,15 @@ def assert_equal(expected, actual) # REMOVE_END # STEP_START hincrby +# Recreate the bike:1 hash so this example runs on its own. +r.del('bike:1') +r.hset('bike:1', { + 'model' => 'Deimos', + 'brand' => 'Ergonom', + 'type' => 'Enduro bikes', + 'price' => 4972 +}) + res6 = r.hincrby('bike:1', 'price', 100) puts res6 # 5072 diff --git a/local_examples/rust-async/dt-hash.rs b/local_examples/rust-async/dt-hash.rs index 0ed0dc981b..59bf7319d0 100644 --- a/local_examples/rust-async/dt-hash.rs +++ b/local_examples/rust-async/dt-hash.rs @@ -93,6 +93,13 @@ mod tests { // STEP_END // STEP_START hmget + // Recreate the bike:1 hash so this example runs on its own. + let _: () = r.del("bike:1").await.expect("Failed to del"); + let _: () = r.hset_multiple( + "bike:1", + &[("model", "Deimos"), ("brand", "Ergonom"), ("type", "Enduro bikes"), ("price", "4972")], + ).await.expect("Failed to hset"); + match r.hmget("bike:1", &["model", "price"]).await { Ok(res) => { let res: Vec = res; @@ -111,6 +118,13 @@ mod tests { // STEP_END // STEP_START hincrby + // Recreate the bike:1 hash so this example runs on its own. + let _: () = r.del("bike:1").await.expect("Failed to del"); + let _: () = r.hset_multiple( + "bike:1", + &[("model", "Deimos"), ("brand", "Ergonom"), ("type", "Enduro bikes"), ("price", "4972")], + ).await.expect("Failed to hset"); + if let Ok(res) = r.hincr("bike:1", "price", 100).await { let res: i32 = res; println!("{res}"); // >>> 5072 diff --git a/local_examples/rust-sync/dt-hash.rs b/local_examples/rust-sync/dt-hash.rs index a052b67250..ff47ceb3c9 100644 --- a/local_examples/rust-sync/dt-hash.rs +++ b/local_examples/rust-sync/dt-hash.rs @@ -93,6 +93,13 @@ mod hash_tests { // STEP_END // STEP_START hmget + // Recreate the bike:1 hash so this example runs on its own. + let _: () = r.del("bike:1").expect("Failed to del"); + let _: () = r.hset_multiple( + "bike:1", + &[("model", "Deimos"), ("brand", "Ergonom"), ("type", "Enduro bikes"), ("price", "4972")], + ).expect("Failed to hset"); + match r.hmget("bike:1", &["model", "price"]) { Ok(res) => { let res: Vec = res; @@ -111,6 +118,13 @@ mod hash_tests { // STEP_END // STEP_START hincrby + // Recreate the bike:1 hash so this example runs on its own. + let _: () = r.del("bike:1").expect("Failed to del"); + let _: () = r.hset_multiple( + "bike:1", + &[("model", "Deimos"), ("brand", "Ergonom"), ("type", "Enduro bikes"), ("price", "4972")], + ).expect("Failed to hset"); + if let Ok(res) = r.hincr("bike:1", "price", 100) { let res: i32 = res; println!("{res}"); // >>> 5072 diff --git a/local_examples/tmp/datatypes/hashes/HashExample.cs b/local_examples/tmp/datatypes/hashes/HashExample.cs index cd8eb03ff2..acd90d75da 100644 --- a/local_examples/tmp/datatypes/hashes/HashExample.cs +++ b/local_examples/tmp/datatypes/hashes/HashExample.cs @@ -66,6 +66,15 @@ public void Run() //REMOVE_END //STEP_START hmget + // Recreate the bike:1 hash so this example runs on its own. + db.KeyDelete("bike:1"); + db.HashSet("bike:1", [ + new("model", "Deimos"), + new("brand", "Ergonom"), + new("type", "Enduro bikes"), + new("price", 4972) + ]); + var values = db.HashGet("bike:1", ["model", "price"]); Console.WriteLine(string.Join(" ", values)); // Deimos 4972 @@ -76,6 +85,15 @@ public void Run() //STEP_END //STEP_START hincrby + // Recreate the bike:1 hash so this example runs on its own. + db.KeyDelete("bike:1"); + db.HashSet("bike:1", [ + new("model", "Deimos"), + new("brand", "Ergonom"), + new("type", "Enduro bikes"), + new("price", 4972) + ]); + var newPrice = db.HashIncrement("bike:1", "price", 100); Console.WriteLine($"New price: {newPrice}"); //REMOVE_START diff --git a/local_examples/tmp/datatypes/hashes/HashExample.java b/local_examples/tmp/datatypes/hashes/HashExample.java index 8274dd173d..f4aee681a4 100644 --- a/local_examples/tmp/datatypes/hashes/HashExample.java +++ b/local_examples/tmp/datatypes/hashes/HashExample.java @@ -53,6 +53,10 @@ public void run() { // REMOVE_END // STEP_START hmget + // Recreate the bike:1 hash so this example runs on its own. + jedis.del("bike:1"); + jedis.hset("bike:1", bike1); + List res5 = jedis.hmget("bike:1", "model", "price"); System.out.println(res5); // [Deimos, 4972] // STEP_END @@ -62,6 +66,10 @@ public void run() { // REMOVE_END // STEP_START hincrby + // Recreate the bike:1 hash so this example runs on its own. + jedis.del("bike:1"); + jedis.hset("bike:1", bike1); + Long res6 = jedis.hincrBy("bike:1", "price", 100); System.out.println(res6); // 5072 Long res7 = jedis.hincrBy("bike:1", "price", -100); diff --git a/local_examples/tmp/datatypes/hashes/dt-hash.js b/local_examples/tmp/datatypes/hashes/dt-hash.js index 3496620e0e..fb4315e85f 100644 --- a/local_examples/tmp/datatypes/hashes/dt-hash.js +++ b/local_examples/tmp/datatypes/hashes/dt-hash.js @@ -50,6 +50,18 @@ assert.deepEqual(res4, { // REMOVE_END // STEP_START hmGet +// Recreate the bike:1 hash so this example runs on its own. +await client.del('bike:1') +await client.hSet( + 'bike:1', + { + 'model': 'Deimos', + 'brand': 'Ergonom', + 'type': 'Enduro bikes', + 'price': 4972, + } +) + const res5 = await client.hmGet('bike:1', ['model', 'price']) console.log(res5) // ['Deimos', '4972'] // STEP_END @@ -59,6 +71,18 @@ assert.deepEqual(Object.values(res5), ['Deimos', '4972']) // REMOVE_END // STEP_START hIncrBy +// Recreate the bike:1 hash so this example runs on its own. +await client.del('bike:1') +await client.hSet( + 'bike:1', + { + 'model': 'Deimos', + 'brand': 'Ergonom', + 'type': 'Enduro bikes', + 'price': 4972, + } +) + const res6 = await client.hIncrBy('bike:1', 'price', 100) console.log(res6) // 5072 const res7 = await client.hIncrBy('bike:1', 'price', -100) diff --git a/local_examples/tmp/datatypes/hashes/dt_hash.py b/local_examples/tmp/datatypes/hashes/dt_hash.py index 0ffdc793d9..f0445a8909 100644 --- a/local_examples/tmp/datatypes/hashes/dt_hash.py +++ b/local_examples/tmp/datatypes/hashes/dt_hash.py @@ -49,6 +49,18 @@ # REMOVE_END # STEP_START hmget +# Recreate the bike:1 hash so this example runs on its own. +r.delete("bike:1") +r.hset( + "bike:1", + mapping={ + "model": "Deimos", + "brand": "Ergonom", + "type": "Enduro bikes", + "price": 4972, + }, +) + res5 = r.hmget("bike:1", ["model", "price"]) print(res5) # >>> ['Deimos', '4972'] @@ -59,6 +71,18 @@ # REMOVE_END # STEP_START hincrby +# Recreate the bike:1 hash so this example runs on its own. +r.delete("bike:1") +r.hset( + "bike:1", + mapping={ + "model": "Deimos", + "brand": "Ergonom", + "type": "Enduro bikes", + "price": 4972, + }, +) + res6 = r.hincrby("bike:1", "price", 100) print(res6) # >>> 5072 diff --git a/local_examples/tmp/datatypes/hashes/hash_tutorial_test.go b/local_examples/tmp/datatypes/hashes/hash_tutorial_test.go index 359e9ec668..04b1546293 100644 --- a/local_examples/tmp/datatypes/hashes/hash_tutorial_test.go +++ b/local_examples/tmp/datatypes/hashes/hash_tutorial_test.go @@ -107,23 +107,19 @@ func ExampleClient_hmget() { // REMOVE_START // start with fresh database rdb.FlushDB(ctx) - rdb.Del(ctx, "bike:1") // REMOVE_END + // STEP_START hmget + // Recreate the bike:1 hash so this example runs on its own. + rdb.Del(ctx, "bike:1") hashFields := []string{ "model", "Deimos", "brand", "Ergonom", "type", "Enduro bikes", "price", "4972", } + rdb.HSet(ctx, "bike:1", hashFields) - _, err := rdb.HSet(ctx, "bike:1", hashFields).Result() - - if err != nil { - panic(err) - } - - // STEP_START hmget cmdReturn := rdb.HMGet(ctx, "bike:1", "model", "price") res5, err := cmdReturn.Result() @@ -167,23 +163,19 @@ func ExampleClient_hincrby() { // REMOVE_START // start with fresh database rdb.FlushDB(ctx) - rdb.Del(ctx, "bike:1") // REMOVE_END + // STEP_START hincrby + // Recreate the bike:1 hash so this example runs on its own. + rdb.Del(ctx, "bike:1") hashFields := []string{ "model", "Deimos", "brand", "Ergonom", "type", "Enduro bikes", "price", "4972", } + rdb.HSet(ctx, "bike:1", hashFields) - _, err := rdb.HSet(ctx, "bike:1", hashFields).Result() - - if err != nil { - panic(err) - } - - // STEP_START hincrby res6, err := rdb.HIncrBy(ctx, "bike:1", "price", 100).Result() if err != nil { diff --git a/local_examples/tmp/lettuce-async/HashExample.java b/local_examples/tmp/lettuce-async/HashExample.java new file mode 100644 index 0000000000..a1eafbe2f1 --- /dev/null +++ b/local_examples/tmp/lettuce-async/HashExample.java @@ -0,0 +1,171 @@ +// EXAMPLE: hash_tutorial +package io.redis.examples.async; + +import io.lettuce.core.*; +import io.lettuce.core.api.async.RedisAsyncCommands; +import io.lettuce.core.api.StatefulRedisConnection; + +// REMOVE_START +import org.junit.jupiter.api.Test; +// REMOVE_END +import java.util.*; +import java.util.concurrent.CompletableFuture; +// REMOVE_START +import static org.assertj.core.api.Assertions.assertThat; +// REMOVE_END + +public class HashExample { + + @Test + public void run() { + RedisClient redisClient = RedisClient.create("redis://localhost:6379"); + + try (StatefulRedisConnection connection = redisClient.connect()) { + RedisAsyncCommands asyncCommands = connection.async(); + // REMOVE_START + CompletableFuture delResult = asyncCommands.del("bike:1", "bike:1:stats").toCompletableFuture(); + + // REMOVE_END + + // STEP_START set_get_all + Map bike1 = new HashMap<>(); + bike1.put("model", "Deimos"); + bike1.put("brand", "Ergonom"); + bike1.put("type", "Enduro bikes"); + bike1.put("price", "4972"); + + CompletableFuture setGetAll = asyncCommands.hset("bike:1", bike1).thenCompose(res1 -> { + System.out.println(res1); // >>> 4 + // REMOVE_START + assertThat(res1).isEqualTo(4); + // REMOVE_END + return asyncCommands.hget("bike:1", "model"); + }).thenCompose(res2 -> { + System.out.println(res2); // >>> Deimos + // REMOVE_START + assertThat(res2).isEqualTo("Deimos"); + // REMOVE_END + return asyncCommands.hget("bike:1", "price"); + }).thenCompose(res3 -> { + System.out.println(res3); // >>> 4972 + // REMOVE_START + assertThat(res3).isEqualTo("4972"); + // REMOVE_END + return asyncCommands.hgetall("bike:1"); + }) + // REMOVE_START + .thenApply(res -> { + assertThat(res.get("type")).isEqualTo("Enduro bikes"); + assertThat(res.get("brand")).isEqualTo("Ergonom"); + assertThat(res.get("price")).isEqualTo("4972"); + assertThat(res.get("model")).isEqualTo("Deimos"); + + return res; + }) + // REMOVE_END + .thenAccept(System.out::println) + // >>> {type=Enduro bikes, brand=Ergonom, price=4972, model=Deimos} + .toCompletableFuture(); + // STEP_END + + // STEP_START hmget + // Recreate the bike:1 hash so this example runs on its own. + CompletableFuture hmGet = setGetAll + .thenCompose(res4 -> asyncCommands.del("bike:1")) + .thenCompose(delRes -> asyncCommands.hset("bike:1", bike1)) + .thenCompose(hsetRes -> asyncCommands.hmget("bike:1", "model", "price")) + // REMOVE_START + .thenApply(res -> { + assertThat(res.toString()).isEqualTo("[KeyValue[model, Deimos], KeyValue[price, 4972]]"); + return res; + }) + // REMOVE_END + .thenAccept(System.out::println) + // [KeyValue[model, Deimos], KeyValue[price, 4972]] + .toCompletableFuture(); + // STEP_END + + // STEP_START hincrby + // Recreate the bike:1 hash so this example runs on its own. + CompletableFuture hIncrBy = hmGet + .thenCompose(r -> asyncCommands.del("bike:1")) + .thenCompose(delRes -> asyncCommands.hset("bike:1", bike1)) + .thenCompose(hsetRes -> asyncCommands.hincrby("bike:1", "price", 100)) + .thenCompose(res6 -> { + System.out.println(res6); // >>> 5072 + // REMOVE_START + assertThat(res6).isEqualTo(5072L); + // REMOVE_END + return asyncCommands.hincrby("bike:1", "price", -100); + }) + // REMOVE_START + .thenApply(res -> { + assertThat(res).isEqualTo(4972L); + return res; + }) + // REMOVE_END + .thenAccept(System.out::println) + // >>> 4972 + .toCompletableFuture(); + // STEP_END + + // STEP_START incrby_get_mget + CompletableFuture incrByGetMget = asyncCommands.hincrby("bike:1:stats", "rides", 1).thenCompose(res7 -> { + System.out.println(res7); // >>> 1 + // REMOVE_START + assertThat(res7).isEqualTo(1L); + // REMOVE_END + return asyncCommands.hincrby("bike:1:stats", "rides", 1); + }).thenCompose(res8 -> { + System.out.println(res8); // >>> 2 + // REMOVE_START + assertThat(res8).isEqualTo(2L); + // REMOVE_END + return asyncCommands.hincrby("bike:1:stats", "rides", 1); + }).thenCompose(res9 -> { + System.out.println(res9); // >>> 3 + // REMOVE_START + assertThat(res9).isEqualTo(3L); + // REMOVE_END + return asyncCommands.hincrby("bike:1:stats", "crashes", 1); + }).thenCompose(res10 -> { + System.out.println(res10); // >>> 1 + // REMOVE_START + assertThat(res10).isEqualTo(1L); + // REMOVE_END + return asyncCommands.hincrby("bike:1:stats", "owners", 1); + }).thenCompose(res11 -> { + System.out.println(res11); // >>> 1 + // REMOVE_START + assertThat(res11).isEqualTo(1L); + // REMOVE_END + return asyncCommands.hget("bike:1:stats", "rides"); + }).thenCompose(res12 -> { + System.out.println(res12); // >>> 3 + // REMOVE_START + assertThat(res12).isEqualTo("3"); + // REMOVE_END + return asyncCommands.hmget("bike:1:stats", "crashes", "owners"); + }) + // REMOVE_START + .thenApply(res -> { + assertThat(res.toString()).isEqualTo("[KeyValue[crashes, 1], KeyValue[owners, 1]]"); + return res; + }) + // REMOVE_END + .thenAccept(System.out::println) + // >>> [KeyValue[crashes, 1], KeyValue[owners, 1]] + .toCompletableFuture(); + // STEP_END + + CompletableFuture.allOf( + // REMOVE_START + delResult, + // REMOVE_END + hIncrBy, incrByGetMget).join(); + } finally { + redisClient.shutdown(); + } + } + +} diff --git a/local_examples/tmp/lettuce-reactive/HashExample.java b/local_examples/tmp/lettuce-reactive/HashExample.java index 44f9678438..d4cf51e33a 100644 --- a/local_examples/tmp/lettuce-reactive/HashExample.java +++ b/local_examples/tmp/lettuce-reactive/HashExample.java @@ -73,7 +73,10 @@ public void run() { // STEP_END // STEP_START hmget - Mono>> hmGet = reactiveCommands.hmget("bike:1", "model", "price").collectList() + // Recreate the bike:1 hash so this example runs on its own. + Mono>> hmGet = reactiveCommands.del("bike:1") + .then(reactiveCommands.hset("bike:1", bike1)) + .then(reactiveCommands.hmget("bike:1", "model", "price").collectList()) .doOnNext(result -> { System.out.println(result); // >>> [KeyValue[model, Deimos], KeyValue[price, 4972]] @@ -85,10 +88,15 @@ public void run() { }); // STEP_END - Mono.when(getModel, getPrice, getAll, hmGet).block(); + // Run the set_get_all reads together, then the self-contained hmget. + Mono.when(getModel, getPrice, getAll).block(); + hmGet.block(); // STEP_START hincrby - Mono hIncrBy = reactiveCommands.hincrby("bike:1", "price", 100).doOnNext(result -> { + // Recreate the bike:1 hash so this example runs on its own. + Mono hIncrBy = reactiveCommands.del("bike:1") + .then(reactiveCommands.hset("bike:1", bike1)) + .then(reactiveCommands.hincrby("bike:1", "price", 100)).doOnNext(result -> { System.out.println(result); // >>> 5072 // REMOVE_START assertThat(result).isEqualTo(5072L); From 6d7f19d871e608d64f5632996e0e208ad634f161 Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Wed, 8 Jul 2026 15:48:07 +0100 Subject: [PATCH 05/28] DOC-6823 make timeseries dependent examples runnable in the interactive CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes try_it="false" from the seven dependent timeseries examples (madd, get, range_filter, agg, agg_align, comp_add, del) and prepends a recreate to each so it runs standalone. Every example uses explicit timestamps, so the recreates are fully deterministic — each CLI sequence was run against live Redis and reproduces the documented output exactly (e.g. agg's 1.95/2.10/1.78 buckets, comp_add's empty→(0,75), del's sample counts). Applies the matching recreates to the redis-py client and wires time_series_tutorial into build/example-test-harness. Only the leading DEL count is cosmetic (shown as the top-to-bottom value), same accepted diff as zrank. Out of scope but noted: the redis-py harness run surfaced a pre-existing staleness in create_compaction (not opted out) — it asserts the old list-of-lists TS.INFO `.rules` format, but redis-py 8.0.1 returns a dict, and the upstream redis-py doctest has the same stale assertion. Left for a redis-py-side fix. Learned: timeseries examples use explicit timestamps, so recreates are deterministic; TS.INFO memoryUsage / `.rules` are display/version-dependent, not the assertion basis for the opted-out steps Directive: don't "fix" the leading DEL counts in these examples — cosmetic cold-sandbox 0-vs-N, the accepted diff established by zrank Ticket: DOC-6823 Co-Authored-By: Claude Opus 4.8 (1M context) --- build/example-test-harness/run.sh | 5 ++ .../develop/data-types/timeseries/_index.md | 79 +++++++++++++++++-- .../redis-py/dt_time_series.py | 55 +++++++++++++ 3 files changed, 132 insertions(+), 7 deletions(-) diff --git a/build/example-test-harness/run.sh b/build/example-test-harness/run.sh index 6b8cbf5fd4..2c98fc4aa2 100755 --- a/build/example-test-harness/run.sh +++ b/build/example-test-harness/run.sh @@ -55,6 +55,11 @@ src_path() { hash_tutorial:lettuce-reactive)echo local_examples/tmp/lettuce-reactive/HashExample.java ;; hash_tutorial:php) echo local_examples/php/DtHashTest.php ;; hash_tutorial:dotnet) echo local_examples/tmp/datatypes/hashes/HashExample.cs ;; + time_series_tutorial:python) echo local_examples/time_series_tutorial/redis-py/dt_time_series.py ;; + time_series_tutorial:go) echo local_examples/time_series_tutorial/go-redis/timeseries_tut_test.go ;; + time_series_tutorial:jedis) echo local_examples/time_series_tutorial/jedis/TimeSeriesTutorialExample.java ;; + time_series_tutorial:node) echo local_examples/time_series_tutorial/node-redis/dt-time-series.js ;; + time_series_tutorial:dotnet) echo local_examples/time_series_tutorial/nredisstack/TimeSeriesTutorial.cs ;; *) echo "" ;; esac } diff --git a/content/develop/data-types/timeseries/_index.md b/content/develop/data-types/timeseries/_index.md index 80c2c8c290..f8d34f9827 100644 --- a/content/develop/data-types/timeseries/_index.md +++ b/content/develop/data-types/timeseries/_index.md @@ -129,7 +129,14 @@ is an array containing the number of samples in each time series after the opera If you use the `*` character as the timestamp, Redis will record the current Unix time, as reported by the server's clock. -{{< clients-example set="time_series_tutorial" step="madd" description="Batch operations: Add multiple data points to one or more time series using TS.MADD when you need to reduce round trips to the server" difficulty="beginner" buildsUpon="create" try_it="false" >}} +{{< clients-example set="time_series_tutorial" step="madd" description="Batch operations: Add multiple data points to one or more time series using TS.MADD when you need to reduce round trips to the server" difficulty="beginner" buildsUpon="create" >}} +# Recreate the thermometer series so this example runs on its own. +> DEL thermometer:1 thermometer:2 +(integer) 2 +> TS.CREATE thermometer:1 +OK +> TS.ADD thermometer:2 1 10.8 +(integer) 1 > TS.MADD thermometer:1 1 9.2 thermometer:1 2 9.9 thermometer:2 2 10.3 1) (integer) 1 2) (integer) 2 @@ -141,7 +148,14 @@ Unix time, as reported by the server's clock. Use [`TS.GET`]({{< relref "commands/ts.get/" >}}) to retrieve the data point with the highest timestamp in a time series. This returns both the timestamp and the value. -{{< clients-example set="time_series_tutorial" step="get" description="Foundational: Use TS.GET to get the latest value and timestamp" difficulty="beginner" buildsUpon="madd" try_it="false" >}} +{{< clients-example set="time_series_tutorial" step="get" description="Foundational: Use TS.GET to get the latest value and timestamp" difficulty="beginner" buildsUpon="madd" >}} +# Recreate the thermometer:2 series so this example runs on its own. +> DEL thermometer:2 +(integer) 1 +> TS.ADD thermometer:2 1 10.8 +(integer) 1 +> TS.ADD thermometer:2 2 10.3 +(integer) 2 # The last recorded temperature for thermometer:2 # was 10.3 at time 2ms. > TS.GET thermometer:2 @@ -224,7 +238,18 @@ use this option). Specify a minimum and maximum value to include only samples within that range. The value range is inclusive and you can use the same value for the minimum and maximum to filter for a single value. -{{< clients-example set="time_series_tutorial" step="range_filter" description="Filtering results: Use FILTER_BY_TS and FILTER_BY_VALUE options with range queries when you need to select specific timestamps or value ranges" difficulty="intermediate" buildsUpon="range" try_it="false" >}} +{{< clients-example set="time_series_tutorial" step="range_filter" description="Filtering results: Use FILTER_BY_TS and FILTER_BY_VALUE options with range queries when you need to select specific timestamps or value ranges" difficulty="intermediate" buildsUpon="range" >}} +# Recreate the rg:1 series so this example runs on its own. +> DEL rg:1 +(integer) 1 +> TS.CREATE rg:1 +OK +> TS.MADD rg:1 0 18 rg:1 1 14 rg:1 2 22 rg:1 3 18 rg:1 4 24 +1) (integer) 0 +2) (integer) 1 +3) (integer) 2 +4) (integer) 3 +5) (integer) 4 > TS.RANGE rg:1 - + FILTER_BY_TS 0 2 4 1) 1) (integer) 0 2) 18 @@ -398,7 +423,18 @@ For example, the example below shows an aggregation with the `avg` aggregator ov five data points in the `rg:2` time series. The bucket size is 2ms, so there are three aggregated values with only one value used to calculate the average for the last bucket. -{{< clients-example set="time_series_tutorial" step="agg" description="Aggregation: Use AGGREGATION option with range queries to compute statistics such as avg, sum, min, and max over time buckets when you need to reduce large datasets" difficulty="intermediate" buildsUpon="madd" try_it="false" >}} +{{< clients-example set="time_series_tutorial" step="agg" description="Aggregation: Use AGGREGATION option with range queries to compute statistics such as avg, sum, min, and max over time buckets when you need to reduce large datasets" difficulty="intermediate" buildsUpon="madd" >}} +# Recreate the rg:2 series so this example runs on its own. +> DEL rg:2 +(integer) 1 +> TS.CREATE rg:2 +OK +> TS.MADD rg:2 0 1.8 rg:2 1 2.1 rg:2 2 2.3 rg:2 3 1.9 rg:2 4 1.78 +1) (integer) 0 +2) (integer) 1 +3) (integer) 2 +4) (integer) 3 +5) (integer) 4 > TS.RANGE rg:2 - + AGGREGATION avg 2 1) 1) (integer) 0 2) 1.9500000000000002 @@ -457,7 +493,20 @@ Bucket(25ms): |_________________________||_________________________||___________ You can also align the buckets to the start or end of the query range. For example, the following command aligns the buckets to the start of the query range at time 10. -{{< clients-example set="time_series_tutorial" step="agg_align" description="Custom alignment: Use ALIGN option with aggregations to align buckets to query range start/end when you need aggregations relative to specific time boundaries" difficulty="advanced" buildsUpon="agg_bucket" try_it="false" >}} +{{< clients-example set="time_series_tutorial" step="agg_align" description="Custom alignment: Use ALIGN option with aggregations to align buckets to query range start/end when you need aggregations relative to specific time boundaries" difficulty="advanced" buildsUpon="agg_bucket" >}} +# Recreate the sensor3 series so this example runs on its own. +> DEL sensor3 +(integer) 1 +> TS.CREATE sensor3 +OK +> TS.MADD sensor3 10 1000 sensor3 20 2000 sensor3 30 3000 sensor3 40 4000 sensor3 50 5000 sensor3 60 6000 sensor3 70 7000 +1) (integer) 10 +2) (integer) 20 +3) (integer) 30 +4) (integer) 40 +5) (integer) 50 +6) (integer) 60 +7) (integer) 70 > TS.RANGE sensor3 10 70 AGGREGATION min 25 ALIGN start 1) 1) (integer) 10 2) 1000 @@ -671,7 +720,16 @@ produce any data in the compacted series. However, when you add data for time 4 (in the second bucket), the compaction rule computes the minimum value for the first bucket and adds it to the compacted series. -{{< clients-example set="time_series_tutorial" step="comp_add" description="Compaction behavior: Understand how compaction rules process data incrementally, computing aggregates for completed buckets when new data arrives" difficulty="intermediate" buildsUpon="create_compaction" try_it="false" >}} +{{< clients-example set="time_series_tutorial" step="comp_add" description="Compaction behavior: Understand how compaction rules process data incrementally, computing aggregates for completed buckets when new data arrives" difficulty="intermediate" buildsUpon="create_compaction" >}} +# Recreate the compaction rule so this example runs on its own. +> DEL hyg:1 hyg:compacted +(integer) 2 +> TS.CREATE hyg:1 +OK +> TS.CREATE hyg:compacted +OK +> TS.CREATERULE hyg:1 hyg:compacted AGGREGATION min 3 +OK > TS.MADD hyg:1 0 75 hyg:1 1 77 hyg:1 2 78 1) (integer) 0 2) (integer) 1 @@ -702,7 +760,14 @@ that fall within a given timestamp range. The range is inclusive, meaning that samples whose timestamp equals the start or end of the range are deleted. If you want to delete a single timestamp, use it as both the start and end of the range. -{{< clients-example set="time_series_tutorial" step="del" description="Deleting data: Use TS.DEL to remove data points within a timestamp range when you need to clean up or correct historical data" difficulty="beginner" buildsUpon="create" try_it="false" >}} +{{< clients-example set="time_series_tutorial" step="del" description="Deleting data: Use TS.DEL to remove data points within a timestamp range when you need to clean up or correct historical data" difficulty="beginner" buildsUpon="create" >}} +# Recreate the thermometer:1 series so this example runs on its own. +> DEL thermometer:1 +(integer) 1 +> TS.ADD thermometer:1 1 9.2 +(integer) 1 +> TS.ADD thermometer:1 2 9.9 +(integer) 2 > TS.INFO thermometer:1 1) totalSamples 2) (integer) 2 diff --git a/local_examples/time_series_tutorial/redis-py/dt_time_series.py b/local_examples/time_series_tutorial/redis-py/dt_time_series.py index 4eed2a19b3..1db7705e9a 100644 --- a/local_examples/time_series_tutorial/redis-py/dt_time_series.py +++ b/local_examples/time_series_tutorial/redis-py/dt_time_series.py @@ -68,6 +68,11 @@ # REMOVE_END # STEP_START madd +# Recreate the thermometer series so this example runs on its own. +r.delete("thermometer:1", "thermometer:2") +r.ts().create("thermometer:1") +r.ts().add("thermometer:2", 1, 10.8) + res8 = r.ts().madd([ ("thermometer:1", 1, 9.2), ("thermometer:1", 2, 9.9), @@ -80,6 +85,10 @@ # REMOVE_END # STEP_START get +# Recreate the thermometer:2 series so this example runs on its own. +r.delete("thermometer:2") +r.ts().add("thermometer:2", 1, 10.8) +r.ts().add("thermometer:2", 2, 10.3) # The last recorded temperature for thermometer:2 # was 10.3 at time 2. res9 = r.ts().get("thermometer:2") @@ -135,6 +144,17 @@ # REMOVE_END # STEP_START range_filter +# Recreate the rg:1 series so this example runs on its own. +r.delete("rg:1") +r.ts().create("rg:1") +r.ts().madd([ + ("rg:1", 0, 18), + ("rg:1", 1, 14), + ("rg:1", 2, 22), + ("rg:1", 3, 18), + ("rg:1", 4, 24), +]) + res17 = r.ts().range("rg:1", "-", "+", filter_by_ts=[0, 2, 4]) print(res17) # >>> [(0, 18.0), (2, 22.0), (4, 24.0)] @@ -278,6 +298,17 @@ # REMOVE_END # STEP_START agg +# Recreate the rg:2 series so this example runs on its own. +r.delete("rg:2") +r.ts().create("rg:2") +r.ts().madd([ + ("rg:2", 0, 1.8), + ("rg:2", 1, 2.1), + ("rg:2", 2, 2.3), + ("rg:2", 3, 1.9), + ("rg:2", 4, 1.78), +]) + res32 = r.ts().range( "rg:2", "-", "+", aggregation_type="avg", @@ -323,6 +354,19 @@ # REMOVE_END # STEP_START agg_align +# Recreate the sensor3 series so this example runs on its own. +r.delete("sensor3") +r.ts().create("sensor3") +r.ts().madd([ + ("sensor3", 10, 1000), + ("sensor3", 20, 2000), + ("sensor3", 30, 3000), + ("sensor3", 40, 4000), + ("sensor3", 50, 5000), + ("sensor3", 60, 6000), + ("sensor3", 70, 7000), +]) + res36 = r.ts().range( "sensor3", 10, 70, aggregation_type="min", @@ -451,6 +495,12 @@ # REMOVE_END # STEP_START comp_add +# Recreate the compaction rule so this example runs on its own. +r.delete("hyg:1", "hyg:compacted") +r.ts().create("hyg:1") +r.ts().create("hyg:compacted") +r.ts().createrule("hyg:1", "hyg:compacted", "min", 3) + res50 = r.ts().madd([ ("hyg:1", 0, 75), ("hyg:1", 1, 77), @@ -475,6 +525,11 @@ # REMOVE_END # STEP_START del +# Recreate the thermometer:1 series so this example runs on its own. +r.delete("thermometer:1") +r.ts().add("thermometer:1", 1, 9.2) +r.ts().add("thermometer:1", 2, 9.9) + res54 = r.ts().info("thermometer:1") print(res54.total_samples) # >>> 2 print(res54.first_timestamp) # >>> 1 From 0898fadf74e9ab35ae19e27c1427f8d0eb95f62e Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Wed, 8 Jul 2026 15:52:26 +0100 Subject: [PATCH 06/28] DOC-6823 add timeseries recreates to the go-redis client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applies the same seven recreates (madd, get, range_filter, agg, agg_align, comp_add, del) to the go-redis timeseries example so each client tab matches the CLI. Verified end-to-end via build/example-test-harness (go test, all Example Output blocks pass) — go-redis has no equivalent of the redis-py `.rules` version-skew, so the full file runs clean. Ticket: DOC-6823 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../go-redis/timeseries_tut_test.go | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/local_examples/time_series_tutorial/go-redis/timeseries_tut_test.go b/local_examples/time_series_tutorial/go-redis/timeseries_tut_test.go index 1045264e8c..317721bb54 100644 --- a/local_examples/time_series_tutorial/go-redis/timeseries_tut_test.go +++ b/local_examples/time_series_tutorial/go-redis/timeseries_tut_test.go @@ -133,6 +133,11 @@ func ExampleClient_timeseries_add() { // REMOVE_END // STEP_START madd + // Recreate the thermometer series so this example runs on its own. + rdb.Del(ctx, "thermometer:1", "thermometer:2") + rdb.TSCreate(ctx, "thermometer:1") + rdb.TSAdd(ctx, "thermometer:2", 1, 10.8) + res1, err := rdb.TSMAdd(ctx, [][]interface{}{ {"thermometer:1", 1, 9.2}, {"thermometer:1", 2, 9.9}, @@ -146,6 +151,10 @@ func ExampleClient_timeseries_add() { // STEP_END // STEP_START get + // Recreate the thermometer:2 series so this example runs on its own. + rdb.Del(ctx, "thermometer:2") + rdb.TSAdd(ctx, "thermometer:2", 1, 10.8) + rdb.TSAdd(ctx, "thermometer:2", 2, 10.3) // The last recorded temperature for thermometer:2 // was 10.3 at time 2. res2, err := rdb.TSGet(ctx, "thermometer:2").Result() @@ -249,6 +258,17 @@ func ExampleClient_timeseries_range() { // STEP_END // STEP_START range_filter + // Recreate the rg:1 series so this example runs on its own. + rdb.Del(ctx, "rg:1") + rdb.TSCreate(ctx, "rg:1") + rdb.TSMAdd(ctx, [][]interface{}{ + {"rg:1", 0, 18}, + {"rg:1", 1, 14}, + {"rg:1", 2, 22}, + {"rg:1", 3, 18}, + {"rg:1", 4, 24}, + }) + res8, err := rdb.TSRangeWithArgs( ctx, "rg:1", @@ -651,6 +671,17 @@ func ExampleClient_timeseries_aggregation() { } // STEP_START agg + // Recreate the rg:2 series so this example runs on its own. + rdb.Del(ctx, "rg:2") + rdb.TSCreate(ctx, "rg:2") + rdb.TSMAdd(ctx, [][]interface{}{ + {"rg:2", 0, 1.8}, + {"rg:2", 1, 2.1}, + {"rg:2", 2, 2.3}, + {"rg:2", 3, 1.9}, + {"rg:2", 4, 1.78}, + }) + res32, err := rdb.TSRangeWithArgs( ctx, "rg:2", @@ -749,6 +780,19 @@ func ExampleClient_timeseries_agg_bucket() { // STEP_END // STEP_START agg_align + // Recreate the sensor3 series so this example runs on its own. + rdb.Del(ctx, "sensor3") + rdb.TSCreate(ctx, "sensor3") + rdb.TSMAdd(ctx, [][]interface{}{ + {"sensor3", 10, 1000}, + {"sensor3", 20, 2000}, + {"sensor3", 30, 3000}, + {"sensor3", 40, 4000}, + {"sensor3", 50, 5000}, + {"sensor3", 60, 6000}, + {"sensor3", 70, 7000}, + }) + res4, err := rdb.TSRangeWithArgs( ctx, "sensor3", @@ -1040,6 +1084,12 @@ func ExampleClient_timeseries_compaction() { // STEP_END // STEP_START comp_add + // Recreate the compaction rule so this example runs on its own. + rdb.Del(ctx, "hyg:1", "hyg:compacted") + rdb.TSCreate(ctx, "hyg:1") + rdb.TSCreate(ctx, "hyg:compacted") + rdb.TSCreateRule(ctx, "hyg:1", "hyg:compacted", redis.Min, 3) + res50, err := rdb.TSMAdd(ctx, [][]interface{}{ {"hyg:1", 0, 75}, {"hyg:1", 1, 77}, @@ -1111,6 +1161,11 @@ func ExampleClient_timeseries_delete() { // REMOVE_END // STEP_START del + // Recreate the thermometer:1 series so this example runs on its own. + rdb.Del(ctx, "thermometer:1") + rdb.TSAdd(ctx, "thermometer:1", 1, 9.2) + rdb.TSAdd(ctx, "thermometer:1", 2, 9.9) + res54, err := rdb.TSInfo(ctx, "thermometer:1").Result() if err != nil { panic(err) From 24232305f497b1629ae7c468629cba8a3cacd781 Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Wed, 8 Jul 2026 16:07:27 +0100 Subject: [PATCH 07/28] DOC-6823 add timeseries recreates to jedis, node, and C# clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creates local overrides for the three upstream-only timeseries clients (jedis, node-redis, NRedisStack) with the same seven recreates as the CLI/python/go steps, completing client-tab parity across all five rendered timeseries clients. Adds NRedisStack + bumps StackExchange.Redis to 3.0.0 in the harness dotnet project (the C# timeseries example uses db.TS()); confirmed the bump doesn't regress the earlier C# examples. Verification via build/example-test-harness: Go, Jedis, and C# full files pass end-to-end. Jedis timeseries needs jedis 8.x (RedisClient) whereas the other jedis examples use 5.x UnifiedJedis, so the harness pom stays 5.2.0 and jedis timeseries was verified via a one-off 8.x bump. Python and Node full-file runs are blocked by pre-existing, out-of-scope version staleness in non-opted-out steps (redis-py `.rules` dict; node float precision on query_multi's mGet) — the recreates in those files are validated via the shared redis-cli sequences run earlier. Learned: timeseries client examples span client-library generations — jedis TS uses RedisClient (8.x) while other jedis examples use UnifiedJedis (5.x), so no single harness pin runs both; NRedisStack 1.6 forces StackExchange.Redis >= 3.0.0 Ticket: DOC-6823 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dotnet/harness.csproj | 3 +- .../jedis/TimeSeriesTutorialExample.java | 717 +++++++++++++++ .../node-redis/dt-time-series.js | 690 +++++++++++++++ .../nredisstack/TimeSeriesTutorial.cs | 833 ++++++++++++++++++ 4 files changed, 2242 insertions(+), 1 deletion(-) create mode 100644 local_examples/time_series_tutorial/jedis/TimeSeriesTutorialExample.java create mode 100644 local_examples/time_series_tutorial/node-redis/dt-time-series.js create mode 100644 local_examples/time_series_tutorial/nredisstack/TimeSeriesTutorial.cs diff --git a/build/example-test-harness/dotnet/harness.csproj b/build/example-test-harness/dotnet/harness.csproj index 38467f7290..883b5dd0d1 100644 --- a/build/example-test-harness/dotnet/harness.csproj +++ b/build/example-test-harness/dotnet/harness.csproj @@ -7,7 +7,8 @@ false - + + diff --git a/local_examples/time_series_tutorial/jedis/TimeSeriesTutorialExample.java b/local_examples/time_series_tutorial/jedis/TimeSeriesTutorialExample.java new file mode 100644 index 0000000000..6349154166 --- /dev/null +++ b/local_examples/time_series_tutorial/jedis/TimeSeriesTutorialExample.java @@ -0,0 +1,717 @@ +// EXAMPLE: time_series_tutorial +// REMOVE_START +package io.redis.examples; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; +// REMOVE_END +import redis.clients.jedis.RedisClient; +import redis.clients.jedis.timeseries.*; +import redis.clients.jedis.timeseries.TSElement; + +import java.util.*; + +public class TimeSeriesTutorialExample { + + @Test + public void run() { + RedisClient jedis = RedisClient.create("redis://localhost:6379"); + // REMOVE_START + // Clear any keys before using them in tests + jedis.del( + "thermometer:1", "thermometer:2", "thermometer:3", + "rg:1", "rg:2", "rg:3", "rg:4", + "sensor3", + "wind:1", "wind:2", "wind:3", "wind:4", + "hyg:1", "hyg:compacted" + ); + // REMOVE_END + + // STEP_START create + String res1 = jedis.tsCreate("thermometer:1"); + System.out.println(res1); // >>> OK + + String res2 = jedis.type("thermometer:1"); + System.out.println(res2); // >>> TSDB-TYPE + + TSInfo res3 = jedis.tsInfo("thermometer:1"); + System.out.println(res3.getProperty("totalSamples")); // >>> 0 + // STEP_END + // REMOVE_START + assertEquals("OK", res1); + assertEquals("TSDB-TYPE", res2); + assertEquals((Long) 0L, res3.getProperty("totalSamples")); + // REMOVE_END + + // STEP_START create_retention + long res4 = jedis.tsAdd("thermometer:2", 1L, 10.8, + TSCreateParams.createParams().retention(100)); + System.out.println(res4); // >>> 1 + + TSInfo res5 = jedis.tsInfo("thermometer:2"); + System.out.println(res5.getProperty("retentionTime")); // >>> 100 + // STEP_END + // REMOVE_START + assertEquals(1L, res4); + assertEquals((Long) 100L, res5.getProperty("retentionTime")); + // REMOVE_END + + // STEP_START create_labels + Map labels = new HashMap<>(); + labels.put("location", "UK"); + labels.put("type", "Mercury"); + + long res6 = jedis.tsAdd("thermometer:3", 1L, 10.4, + TSCreateParams.createParams().labels(labels)); + System.out.println(res6); // >>> 1 + + TSInfo res7 = jedis.tsInfo("thermometer:3"); + System.out.println("Labels: " + res7.getLabels()); + // >>> Labels: {location=UK, type=Mercury} + // STEP_END + // REMOVE_START + assertEquals(1L, res6); + assertEquals(labels, res7.getLabels()); + // REMOVE_END + + // STEP_START madd + // Recreate the thermometer series so this example runs on its own. + jedis.del("thermometer:1", "thermometer:2"); + jedis.tsCreate("thermometer:1"); + jedis.tsAdd("thermometer:2", 1L, 10.8); + + List res8 = jedis.tsMAdd( + new AbstractMap.SimpleEntry<>("thermometer:1", new TSElement(1L, 9.2)), + new AbstractMap.SimpleEntry<>("thermometer:1", new TSElement(2L, 9.9)), + new AbstractMap.SimpleEntry<>("thermometer:2", new TSElement(2L, 10.3)) + ); + System.out.println(res8); // >>> [1, 2, 2] + // STEP_END + // REMOVE_START + assertEquals(Arrays.asList(1L, 2L, 2L), res8); + // REMOVE_END + + // STEP_START get + // Recreate the thermometer:2 series so this example runs on its own. + jedis.del("thermometer:2"); + jedis.tsAdd("thermometer:2", 1L, 10.8); + jedis.tsAdd("thermometer:2", 2L, 10.3); + // The last recorded temperature for thermometer:2 + // was 10.3 at time 2. + TSElement res9 = jedis.tsGet("thermometer:2"); + System.out.println("(" + res9.getTimestamp() + ", " + res9.getValue() + ")"); + // >>> (2, 10.3) + // STEP_END + // REMOVE_START + assertEquals(2L, res9.getTimestamp()); + assertEquals(10.3, res9.getValue(), 0.001); + // REMOVE_END + + // STEP_START range + // Add 5 data points to a time series named "rg:1" + String res10 = jedis.tsCreate("rg:1"); + System.out.println(res10); // >>> OK + + List res11 = jedis.tsMAdd( + new AbstractMap.SimpleEntry<>("rg:1", new TSElement(0L, 18.0)), + new AbstractMap.SimpleEntry<>("rg:1", new TSElement(1L, 14.0)), + new AbstractMap.SimpleEntry<>("rg:1", new TSElement(2L, 22.0)), + new AbstractMap.SimpleEntry<>("rg:1", new TSElement(3L, 18.0)), + new AbstractMap.SimpleEntry<>("rg:1", new TSElement(4L, 24.0)) + ); + System.out.println(res11); // >>> [0, 1, 2, 3, 4] + + // Retrieve all the data points in ascending order + List res12 = jedis.tsRange("rg:1", 0L, 4L); + System.out.println(res12); + // >>> [(0:18.0), (1:14.0), (2:22.0), (3:18.0), (4:24.0)] + + // Retrieve data points up to time 1 (inclusive) + List res13 = jedis.tsRange("rg:1", 0L, 1L); + System.out.println(res13); + // >>> [(0:18.0), (1:14.0)] + + // Retrieve data points from time 3 onwards + List res14 = jedis.tsRange("rg:1", 3L, 4L); + System.out.println(res14); + // >>> [(3:18.0), (4:24.0)] + + // Retrieve all the data points in descending order + List res15 = jedis.tsRevRange("rg:1", 0L, 4L); + System.out.println(res15); + // >>> [(4:24.0), (3:18.0), (2:22.0), (1:14.0), (0:18.0)] + + // Retrieve data points up to time 1 (inclusive), in descending order + List res16 = jedis.tsRevRange("rg:1", 0L, 1L); + System.out.println(res16); + // >>> [(1:14.0), (0:18.0)] + // STEP_END + // REMOVE_START + assertEquals("OK", res10); + assertEquals(Arrays.asList(0L, 1L, 2L, 3L, 4L), res11); + assertEquals(Arrays.asList( + new TSElement(0L, 18.0), new TSElement(1L, 14.0), new TSElement(2L, 22.0), + new TSElement(3L, 18.0), new TSElement(4L, 24.0)), res12); + assertEquals(Arrays.asList(new TSElement(0L, 18.0), new TSElement(1L, 14.0)), res13); + assertEquals(Arrays.asList(new TSElement(3L, 18.0), new TSElement(4L, 24.0)), res14); + assertEquals(Arrays.asList( + new TSElement(4L, 24.0), new TSElement(3L, 18.0), new TSElement(2L, 22.0), + new TSElement(1L, 14.0), new TSElement(0L, 18.0)), res15); + assertEquals(Arrays.asList(new TSElement(1L, 14.0), new TSElement(0L, 18.0)), res16); + // REMOVE_END + + // STEP_START range_filter + // Recreate the rg:1 series so this example runs on its own. + jedis.del("rg:1"); + jedis.tsCreate("rg:1"); + jedis.tsMAdd( + new AbstractMap.SimpleEntry<>("rg:1", new TSElement(0L, 18.0)), + new AbstractMap.SimpleEntry<>("rg:1", new TSElement(1L, 14.0)), + new AbstractMap.SimpleEntry<>("rg:1", new TSElement(2L, 22.0)), + new AbstractMap.SimpleEntry<>("rg:1", new TSElement(3L, 18.0)), + new AbstractMap.SimpleEntry<>("rg:1", new TSElement(4L, 24.0)) + ); + + List res17 = jedis.tsRange("rg:1", + TSRangeParams.rangeParams() + .fromTimestamp(0L) + .toTimestamp(4L) + .filterByTS(0L, 2L, 4L) + ); + System.out.println(res17); + // >>> [(0:18.0), (2:22.0), (4:24.0)] + + List res18 = jedis.tsRevRange("rg:1", + TSRangeParams.rangeParams() + .fromTimestamp(0L) + .toTimestamp(4L) + .filterByTS(0L, 2L, 4L) + .filterByValues(20.0, 25.0) + ); + System.out.println(res18); + // >>> [(4:24.0), (2:22.0)] + + List res19 = jedis.tsRevRange("rg:1", + TSRangeParams.rangeParams() + .fromTimestamp(0L) + .toTimestamp(4L) + .filterByTS(0L, 2L, 4L) + .filterByValues(22.0, 22.0) + .count(1) + ); + System.out.println(res19); + // >>> [(2:22.0)] + // STEP_END + // REMOVE_START + assertEquals(Arrays.asList( + new TSElement(0L, 18.0), new TSElement(2L, 22.0), new TSElement(4L, 24.0)), res17); + assertEquals(Arrays.asList(new TSElement(4L, 24.0), new TSElement(2L, 22.0)), res18); + assertEquals(Arrays.asList(new TSElement(2L, 22.0)), res19); + // REMOVE_END + + // STEP_START query_multi + // Create three new "rg:" time series (two in the US + // and one in the UK, with different units) and add some + // data points. + Map usLabels1 = new HashMap<>(); + usLabels1.put("location", "us"); + usLabels1.put("unit", "cm"); + + Map usLabels2 = new HashMap<>(); + usLabels2.put("location", "us"); + usLabels2.put("unit", "in"); + + Map ukLabels = new HashMap<>(); + ukLabels.put("location", "uk"); + ukLabels.put("unit", "mm"); + + String res20 = jedis.tsCreate("rg:2", + TSCreateParams.createParams().labels(usLabels1)); + System.out.println(res20); // >>> OK + + String res21 = jedis.tsCreate("rg:3", + TSCreateParams.createParams().labels(usLabels2)); + System.out.println(res21); // >>> OK + + String res22 = jedis.tsCreate("rg:4", + TSCreateParams.createParams().labels(ukLabels)); + System.out.println(res22); // >>> OK + + List res23 = jedis.tsMAdd( + new AbstractMap.SimpleEntry<>("rg:2", new TSElement(0L, 1.8)), + new AbstractMap.SimpleEntry<>("rg:3", new TSElement(0L, 0.9)), + new AbstractMap.SimpleEntry<>("rg:4", new TSElement(0L, 25.0)) + ); + System.out.println(res23); // >>> [0, 0, 0] + + List res24 = jedis.tsMAdd( + new AbstractMap.SimpleEntry<>("rg:2", new TSElement(1L, 2.1)), + new AbstractMap.SimpleEntry<>("rg:3", new TSElement(1L, 0.77)), + new AbstractMap.SimpleEntry<>("rg:4", new TSElement(1L, 18.0)) + ); + System.out.println(res24); // >>> [1, 1, 1] + + List res25 = jedis.tsMAdd( + new AbstractMap.SimpleEntry<>("rg:2", new TSElement(2L, 2.3)), + new AbstractMap.SimpleEntry<>("rg:3", new TSElement(2L, 1.1)), + new AbstractMap.SimpleEntry<>("rg:4", new TSElement(2L, 21.0)) + ); + System.out.println(res25); // >>> [2, 2, 2] + + List res26 = jedis.tsMAdd( + new AbstractMap.SimpleEntry<>("rg:2", new TSElement(3L, 1.9)), + new AbstractMap.SimpleEntry<>("rg:3", new TSElement(3L, 0.81)), + new AbstractMap.SimpleEntry<>("rg:4", new TSElement(3L, 19.0)) + ); + System.out.println(res26); // >>> [3, 3, 3] + + List res27 = jedis.tsMAdd( + new AbstractMap.SimpleEntry<>("rg:2", new TSElement(4L, 1.78)), + new AbstractMap.SimpleEntry<>("rg:3", new TSElement(4L, 0.74)), + new AbstractMap.SimpleEntry<>("rg:4", new TSElement(4L, 23.0)) + ); + System.out.println(res27); // >>> [4, 4, 4] + + // Retrieve the last data point from each US time series. + Map res28 = jedis.tsMGet( + TSMGetParams.multiGetParams().latest(), + "location=us" + ); + System.out.println(res28); + // >>> {rg:2=TSMGetElement{key=rg:2, labels={}, element=(4:1.78)}... + + // Retrieve the same data points, but include the `unit` + // label in the results. + Map res29 = jedis.tsMGet( + TSMGetParams.multiGetParams().selectedLabels("unit"), + "location=us" + ); + System.out.println(res29); + // >>> {rg:2=TSMGetElement{key=rg:2, labels={unit=cm}, element=(4:1.78)}... + + // Retrieve data points up to time 2 (inclusive) from all + // time series that use millimeters as the unit. Include all + // labels in the results. + Map res30 = jedis.tsMRange( + TSMRangeParams.multiRangeParams(0L, 2L) + .withLabels() + .filter("unit=mm") + ); + System.out.println(res30); + // >>> {rg:4=TSMRangeElements{key=rg:4, labels={location=uk, unit=mm}, value=[(0:25.0), (1:18.0), (2:21.0)]}} + + // Retrieve data points from time 1 to time 3 (inclusive) from + // all time series that use centimeters or millimeters as the unit, + // but only return the `location` label. Return the results + // in descending order of timestamp. + Map res31 = jedis.tsMRevRange( + TSMRangeParams.multiRangeParams(1L, 3L) + .selectedLabels("location") + .filter("unit=(cm,mm)") + ); + System.out.println(res31); + // >>> {rg:2=TSMRangeElements{key=rg:2, labels={location=us, unit=cm}, value=[(1:2.1)... + // STEP_END + // REMOVE_START + assertEquals("OK", res20); + assertEquals("OK", res21); + assertEquals("OK", res22); + assertEquals(Arrays.asList(0L, 0L, 0L), res23); + assertEquals(Arrays.asList(1L, 1L, 1L), res24); + assertEquals(Arrays.asList(2L, 2L, 2L), res25); + assertEquals(Arrays.asList(3L, 3L, 3L), res26); + assertEquals(Arrays.asList(4L, 4L, 4L), res27); + assertEquals(2, res28.size()); + + assertTrue(res28.containsKey("rg:2")); + TSMGetElement res28rg2 = res28.get("rg:2"); + assertEquals("rg:2", res28rg2.getKey()); + assertEquals(0, res28rg2.getLabels().size()); + assertEquals(4L, res28rg2.getElement().getTimestamp()); + assertEquals(1.78, res28rg2.getElement().getValue(), 0.001); + + assertTrue(res28.containsKey("rg:3")); + TSMGetElement res28rg3 = res28.get("rg:3"); + assertEquals("rg:3", res28rg3.getKey()); + assertEquals(0, res28rg3.getLabels().size()); + assertEquals(4L, res28rg3.getElement().getTimestamp()); + assertEquals(0.74, res28rg3.getElement().getValue(), 0.001); + + assertEquals(2, res29.size()); + assertTrue(res29.containsKey("rg:2")); + TSMGetElement res29rg2 = res29.get("rg:2"); + assertEquals("rg:2", res29rg2.getKey()); + assertEquals(1, res29rg2.getLabels().size()); + assertEquals("cm", res29rg2.getLabels().get("unit")); + assertEquals(4L, res29rg2.getElement().getTimestamp()); + assertEquals(1.78, res29rg2.getElement().getValue(), 0.001); + + assertEquals(1, res30.size()); + assertTrue(res30.containsKey("rg:4")); + TSMRangeElements res30rg4 = res30.get("rg:4"); + assertEquals("rg:4", res30rg4.getKey()); + assertEquals(2, res30rg4.getLabels().size()); + assertEquals("uk", res30rg4.getLabels().get("location")); + assertEquals("mm", res30rg4.getLabels().get("unit")); + assertEquals(3, res30rg4.getElements().size()); + assertEquals(0L, res30rg4.getElements().get(0).getTimestamp()); + assertEquals(25.0, res30rg4.getElements().get(0).getValue(), 0.001); + assertEquals(1L, res30rg4.getElements().get(1).getTimestamp()); + assertEquals(18.0, res30rg4.getElements().get(1).getValue(), 0.001); + assertEquals(2L, res30rg4.getElements().get(2).getTimestamp()); + assertEquals(21.0, res30rg4.getElements().get(2).getValue(), 0.001); + + assertEquals(2, res31.size()); + assertTrue(res31.containsKey("rg:2")); + TSMRangeElements res31rg2 = res31.get("rg:2"); + assertEquals("rg:2", res31rg2.getKey()); + assertEquals(1, res31rg2.getLabels().size()); + assertEquals("us", res31rg2.getLabels().get("location")); + assertEquals(3, res31rg2.getElements().size()); + assertEquals(3L, res31rg2.getElements().get(0).getTimestamp()); + assertEquals(1.9, res31rg2.getElements().get(0).getValue(), 0.001); + assertEquals(2L, res31rg2.getElements().get(1).getTimestamp()); + assertEquals(2.3, res31rg2.getElements().get(1).getValue(), 0.001); + assertEquals(1L, res31rg2.getElements().get(2).getTimestamp()); + assertEquals(2.1, res31rg2.getElements().get(2).getValue(), 0.001); + + // REMOVE_END + + // STEP_START agg + // Recreate the rg:2 series so this example runs on its own. + jedis.del("rg:2"); + jedis.tsCreate("rg:2"); + jedis.tsMAdd( + new AbstractMap.SimpleEntry<>("rg:2", new TSElement(0L, 1.8)), + new AbstractMap.SimpleEntry<>("rg:2", new TSElement(1L, 2.1)), + new AbstractMap.SimpleEntry<>("rg:2", new TSElement(2L, 2.3)), + new AbstractMap.SimpleEntry<>("rg:2", new TSElement(3L, 1.9)), + new AbstractMap.SimpleEntry<>("rg:2", new TSElement(4L, 1.78)) + ); + + List res32 = jedis.tsRange("rg:2", + TSRangeParams.rangeParams() + .fromTimestamp(0L) + .toTimestamp(4L) + .aggregation(AggregationType.AVG, 2) + ); + System.out.println(res32); + // >>> [(0:1.9500000000000002), (2:2.0999999999999996), (4:1.78)] + // STEP_END + // REMOVE_START + assertEquals( + Arrays.asList( + new TSElement(0L, 1.9500000000000002), + new TSElement(2L, 2.0999999999999996), + new TSElement(4L, 1.78) + ), + res32 + ); + // REMOVE_END + + // STEP_START agg_bucket + String res33 = jedis.tsCreate("sensor3"); + System.out.println(res33); // >>> OK + + List res34 = jedis.tsMAdd( + new AbstractMap.SimpleEntry<>("sensor3", new TSElement(10L, 1000.0)), + new AbstractMap.SimpleEntry<>("sensor3", new TSElement(20L, 2000.0)), + new AbstractMap.SimpleEntry<>("sensor3", new TSElement(30L, 3000.0)), + new AbstractMap.SimpleEntry<>("sensor3", new TSElement(40L, 4000.0)), + new AbstractMap.SimpleEntry<>("sensor3", new TSElement(50L, 5000.0)), + new AbstractMap.SimpleEntry<>("sensor3", new TSElement(60L, 6000.0)), + new AbstractMap.SimpleEntry<>("sensor3", new TSElement(70L, 7000.0)) + ); + System.out.println(res34); // >>> [10, 20, 30, 40, 50, 60, 70] + + List res35 = jedis.tsRange("sensor3", + TSRangeParams.rangeParams() + .fromTimestamp(10L) + .toTimestamp(70L) + .aggregation(AggregationType.MIN, 25) + ); + System.out.println(res35); + // >>> [(0:1000.0), (25:3000.0), (50:5000.0)] + // STEP_END + // REMOVE_START + assertEquals("OK", res33); + assertEquals(Arrays.asList(10L, 20L, 30L, 40L, 50L, 60L, 70L), res34); + assertEquals( + Arrays.asList( + new TSElement(0L, 1000.0), + new TSElement(25L, 3000.0), + new TSElement(50L, 5000.0) + ), + res35 + ); + // REMOVE_END + + // STEP_START agg_align + // Recreate the sensor3 series so this example runs on its own. + jedis.del("sensor3"); + jedis.tsCreate("sensor3"); + jedis.tsMAdd( + new AbstractMap.SimpleEntry<>("sensor3", new TSElement(10L, 1000.0)), + new AbstractMap.SimpleEntry<>("sensor3", new TSElement(20L, 2000.0)), + new AbstractMap.SimpleEntry<>("sensor3", new TSElement(30L, 3000.0)), + new AbstractMap.SimpleEntry<>("sensor3", new TSElement(40L, 4000.0)), + new AbstractMap.SimpleEntry<>("sensor3", new TSElement(50L, 5000.0)), + new AbstractMap.SimpleEntry<>("sensor3", new TSElement(60L, 6000.0)), + new AbstractMap.SimpleEntry<>("sensor3", new TSElement(70L, 7000.0)) + ); + + List res36 = jedis.tsRange("sensor3", + TSRangeParams.rangeParams() + .fromTimestamp(10L) + .toTimestamp(70L) + .aggregation(AggregationType.MIN, 25) + .alignStart() + ); + System.out.println(res36); + // >>> [(10:1000.0), (35:4000.0), (60:6000.0)] + // STEP_END + // REMOVE_START + assertEquals( + Arrays.asList( + new TSElement(10L, 1000.0), + new TSElement(35L, 4000.0), + new TSElement(60L, 6000.0) + ), + res36 + ); + // REMOVE_END + + // STEP_START agg_multi + Map ukCountry = new HashMap<>(); + ukCountry.put("country", "uk"); + + Map usCountry = new HashMap<>(); + usCountry.put("country", "us"); + + jedis.tsCreate("wind:1", TSCreateParams.createParams().labels(ukCountry)); + jedis.tsCreate("wind:2", TSCreateParams.createParams().labels(ukCountry)); + jedis.tsCreate("wind:3", TSCreateParams.createParams().labels(usCountry)); + jedis.tsCreate("wind:4", TSCreateParams.createParams().labels(usCountry)); + + jedis.tsMAdd( + new AbstractMap.SimpleEntry<>("wind:1", new TSElement(0L, 10.0)), + new AbstractMap.SimpleEntry<>("wind:2", new TSElement(0L, 12.0)), + new AbstractMap.SimpleEntry<>("wind:3", new TSElement(0L, 8.0)), + new AbstractMap.SimpleEntry<>("wind:4", new TSElement(0L, 15.0)) + ); + + jedis.tsMAdd( + new AbstractMap.SimpleEntry<>("wind:1", new TSElement(1L, 11.0)), + new AbstractMap.SimpleEntry<>("wind:2", new TSElement(1L, 13.0)), + new AbstractMap.SimpleEntry<>("wind:3", new TSElement(1L, 9.0)), + new AbstractMap.SimpleEntry<>("wind:4", new TSElement(1L, 16.0)) + ); + + jedis.tsMAdd( + new AbstractMap.SimpleEntry<>("wind:1", new TSElement(2L, 9.0)), + new AbstractMap.SimpleEntry<>("wind:2", new TSElement(2L, 11.0)), + new AbstractMap.SimpleEntry<>("wind:3", new TSElement(2L, 7.0)), + new AbstractMap.SimpleEntry<>("wind:4", new TSElement(2L, 14.0)) + ); + + jedis.tsMAdd( + new AbstractMap.SimpleEntry<>("wind:1", new TSElement(3L, 12.0)), + new AbstractMap.SimpleEntry<>("wind:2", new TSElement(3L, 14.0)), + new AbstractMap.SimpleEntry<>("wind:3", new TSElement(3L, 10.0)), + new AbstractMap.SimpleEntry<>("wind:4", new TSElement(3L, 17.0)) + ); + + jedis.tsMAdd( + new AbstractMap.SimpleEntry<>("wind:1", new TSElement(4L, 8.0)), + new AbstractMap.SimpleEntry<>("wind:2", new TSElement(4L, 10.0)), + new AbstractMap.SimpleEntry<>("wind:3", new TSElement(4L, 6.0)), + new AbstractMap.SimpleEntry<>("wind:4", new TSElement(4L, 13.0)) + ); + + // Group by country with max reduction + Map res44 = jedis.tsMRange( + TSMRangeParams.multiRangeParams(0L, 4L) + .filter("country=(us,uk)") + .groupBy("country", "max")); + System.out.println(res44); + // >>> {country=uk=TSMRangeElements{key=country=uk, labels={}, value=[(0:12.0)... + + // Group by country with avg reduction + Map res45 = jedis.tsMRange( + TSMRangeParams.multiRangeParams(0L, 4L) + .filter("country=(us,uk)") + .groupBy("country", "avg")); + System.out.println(res45); + // >>> {country=uk=TSMRangeElements{key=country=uk, labels={}, value=[(0:11.0)... + // STEP_END + // REMOVE_START + assertEquals(2, res44.size()); + assertTrue(res44.containsKey("country=uk")); + TSMRangeElements res44uk = res44.get("country=uk"); + assertEquals("country=uk", res44uk.getKey()); + assertEquals(0, res44uk.getLabels().size()); + assertEquals(5, res44uk.getElements().size()); + assertEquals(0L, res44uk.getElements().get(0).getTimestamp()); + assertEquals(12.0, res44uk.getElements().get(0).getValue(), 0.001); + assertEquals(1L, res44uk.getElements().get(1).getTimestamp()); + assertEquals(13.0, res44uk.getElements().get(1).getValue(), 0.001); + assertEquals(2L, res44uk.getElements().get(2).getTimestamp()); + assertEquals(11.0, res44uk.getElements().get(2).getValue(), 0.001); + assertEquals(3L, res44uk.getElements().get(3).getTimestamp()); + assertEquals(14.0, res44uk.getElements().get(3).getValue(), 0.001); + assertEquals(4L, res44uk.getElements().get(4).getTimestamp()); + assertEquals(10.0, res44uk.getElements().get(4).getValue(), 0.001); + + assertTrue(res44.containsKey("country=us")); + TSMRangeElements res44us = res44.get("country=us"); + assertEquals("country=us", res44us.getKey()); + assertEquals(0, res44us.getLabels().size()); + assertEquals(5, res44us.getElements().size()); + assertEquals(0L, res44us.getElements().get(0).getTimestamp()); + assertEquals(15.0, res44us.getElements().get(0).getValue(), 0.001); + assertEquals(1L, res44us.getElements().get(1).getTimestamp()); + assertEquals(16.0, res44us.getElements().get(1).getValue(), 0.001); + assertEquals(2L, res44us.getElements().get(2).getTimestamp()); + assertEquals(14.0, res44us.getElements().get(2).getValue(), 0.001); + assertEquals(3L, res44us.getElements().get(3).getTimestamp()); + assertEquals(17.0, res44us.getElements().get(3).getValue(), 0.001); + assertEquals(4L, res44us.getElements().get(4).getTimestamp()); + assertEquals(13.0, res44us.getElements().get(4).getValue(), 0.001); + + assertEquals(2, res45.size()); + assertTrue(res45.containsKey("country=uk")); + TSMRangeElements res45uk = res45.get("country=uk"); + assertEquals("country=uk", res45uk.getKey()); + assertEquals(0, res45uk.getLabels().size()); + assertEquals(5, res45uk.getElements().size()); + assertEquals(0L, res45uk.getElements().get(0).getTimestamp()); + assertEquals(11.0, res45uk.getElements().get(0).getValue(), 0.001); + assertEquals(1L, res45uk.getElements().get(1).getTimestamp()); + assertEquals(12.0, res45uk.getElements().get(1).getValue(), 0.001); + assertEquals(2L, res45uk.getElements().get(2).getTimestamp()); + assertEquals(10.0, res45uk.getElements().get(2).getValue(), 0.001); + assertEquals(3L, res45uk.getElements().get(3).getTimestamp()); + assertEquals(13.0, res45uk.getElements().get(3).getValue(), 0.001); + assertEquals(4L, res45uk.getElements().get(4).getTimestamp()); + assertEquals(9.0, res45uk.getElements().get(4).getValue(), 0.001); + + assertTrue(res45.containsKey("country=us")); + TSMRangeElements res45us = res45.get("country=us"); + assertEquals("country=us", res45us.getKey()); + assertEquals(0, res45us.getLabels().size()); + assertEquals(5, res45us.getElements().size()); + assertEquals(0L, res45us.getElements().get(0).getTimestamp()); + assertEquals(11.5, res45us.getElements().get(0).getValue(), 0.001); + assertEquals(1L, res45us.getElements().get(1).getTimestamp()); + assertEquals(12.5, res45us.getElements().get(1).getValue(), 0.001); + assertEquals(2L, res45us.getElements().get(2).getTimestamp()); + assertEquals(10.5, res45us.getElements().get(2).getValue(), 0.001); + assertEquals(3L, res45us.getElements().get(3).getTimestamp()); + assertEquals(13.5, res45us.getElements().get(3).getValue(), 0.001); + assertEquals(4L, res45us.getElements().get(4).getTimestamp()); + assertEquals(9.5, res45us.getElements().get(4).getValue(), 0.001); + // REMOVE_END + + // STEP_START create_compaction + String res46 = jedis.tsCreate("hyg:1"); + System.out.println(res46); // >>> OK + + String res47 = jedis.tsCreate("hyg:compacted"); + System.out.println(res47); // >>> OK + + String res48 = jedis.tsCreateRule("hyg:1", "hyg:compacted", AggregationType.MIN, 3); + System.out.println(res48); // >>> OK + + TSInfo res49 = jedis.tsInfo("hyg:1"); + System.out.println("Rules: " + res49.getProperty("rules")); + // >>> Rules: [{compactionKey=hyg:compacted, bucketDuration=3, aggregationType=MIN, alignmentTimestamp=0}] + + TSInfo res50 = jedis.tsInfo("hyg:compacted"); + System.out.println("Source key: " + res50.getProperty("sourceKey")); + // >>> Source key: hyg:1 + // STEP_END + // REMOVE_START + assertEquals("OK", res46); + assertEquals("OK", res47); + assertEquals("OK", res48); + assertEquals("hyg:1", res50.getProperty("sourceKey")); + // REMOVE_END + + // STEP_START comp_add + // Recreate the compaction rule so this example runs on its own. + jedis.del("hyg:1", "hyg:compacted"); + jedis.tsCreate("hyg:1"); + jedis.tsCreate("hyg:compacted"); + jedis.tsCreateRule("hyg:1", "hyg:compacted", AggregationType.MIN, 3); + + List res51 = jedis.tsMAdd( + new AbstractMap.SimpleEntry<>("hyg:1", new TSElement(0L, 75.0)), + new AbstractMap.SimpleEntry<>("hyg:1", new TSElement(1L, 77.0)), + new AbstractMap.SimpleEntry<>("hyg:1", new TSElement(2L, 78.0)) + ); + System.out.println(res51); // >>> [0, 1, 2] + + List res52 = jedis.tsRange("hyg:compacted", 0L, 10L); + System.out.println(res52); // >>> [] + + long res53 = jedis.tsAdd("hyg:1", 3L, 79.0); + System.out.println(res53); // >>> 3 + + List res54 = jedis.tsRange("hyg:compacted", 0L, 10L); + System.out.println(res54); // >>> [(0:75.0)] + // STEP_END + // REMOVE_START + assertEquals(Arrays.asList(0L, 1L, 2L), res51); + assertEquals(Arrays.asList(), res52); + assertEquals(3L, res53); + assertEquals(Arrays.asList(new TSElement(0L, 75.0)), res54); + // REMOVE_END + + // STEP_START del + // Recreate the thermometer:1 series so this example runs on its own. + jedis.del("thermometer:1"); + jedis.tsAdd("thermometer:1", 1L, 9.2); + jedis.tsAdd("thermometer:1", 2L, 9.9); + + TSInfo res55 = jedis.tsInfo("thermometer:1"); + System.out.println(res55.getProperty("totalSamples")); // >>> 2 + System.out.println(res55.getProperty("firstTimestamp")); // >>> 1 + System.out.println(res55.getProperty("lastTimestamp")); // >>> 2 + + long res56 = jedis.tsAdd("thermometer:1", 3L, 9.7); + System.out.println(res56); // >>> 3 + + TSInfo res57 = jedis.tsInfo("thermometer:1"); + System.out.println(res57.getProperty("totalSamples")); // >>> 3 + + long res58 = jedis.tsDel("thermometer:1", 1L, 2L); + System.out.println(res58); // >>> 2 + + TSInfo res59 = jedis.tsInfo("thermometer:1"); + System.out.println(res59.getProperty("totalSamples")); // >>> 1 + + long res60 = jedis.tsDel("thermometer:1", 3L, 3L); + System.out.println(res60); // >>> 1 + + TSInfo res61 = jedis.tsInfo("thermometer:1"); + System.out.println(res61.getProperty("totalSamples")); // >>> 0 + // STEP_END + // REMOVE_START + assertEquals((Long) 2L, res55.getProperty("totalSamples")); + assertEquals((Long) 1L, res55.getProperty("firstTimestamp")); + assertEquals((Long) 2L, res55.getProperty("lastTimestamp")); + assertEquals(3L, res56); + assertEquals((Long) 3L, res57.getProperty("totalSamples")); + assertEquals(2L, res58); + assertEquals((Long) 1L, res59.getProperty("totalSamples")); + assertEquals(1L, res60); + assertEquals((Long) 0L, res61.getProperty("totalSamples")); + // REMOVE_END + + //HIDE_START + jedis.close(); + //HIDE_END + } +} diff --git a/local_examples/time_series_tutorial/node-redis/dt-time-series.js b/local_examples/time_series_tutorial/node-redis/dt-time-series.js new file mode 100644 index 0000000000..1d5267d8df --- /dev/null +++ b/local_examples/time_series_tutorial/node-redis/dt-time-series.js @@ -0,0 +1,690 @@ +// EXAMPLE: time_series_tutorial +// HIDE_START +import assert from 'assert'; +import { createClient } from 'redis'; +import { TIME_SERIES_AGGREGATION_TYPE, TIME_SERIES_REDUCERS } from '@redis/time-series'; + +const client = createClient(); +await client.connect(); +// HIDE_END + +// REMOVE_START +await client.del([ + 'thermometer:1', 'thermometer:2', 'thermometer:3', + 'rg:1', 'rg:2', 'rg:3', 'rg:4', + 'sensor3', + 'wind:1', 'wind:2', 'wind:3', 'wind:4', + 'hyg:1', 'hyg:compacted' +]); +// REMOVE_END + +// STEP_START create +const res1 = await client.ts.create('thermometer:1'); +console.log(res1); // >>> OK + +const res2 = await client.type('thermometer:1'); +console.log(res2); // >>> TSDB-TYPE + +const res3 = await client.ts.info('thermometer:1'); +console.log(res3); +// >>> { rules: [], ... totalSamples: 0, ... +// STEP_END +// REMOVE_START +assert.equal(res1, 'OK'); +assert.equal(res2, 'TSDB-TYPE'); +assert.equal(res3.totalSamples, 0); +// REMOVE_END + +// STEP_START create_retention +const res4 = await client.ts.add('thermometer:2', 1, 10.8, { RETENTION: 100 }); +console.log(res4); // >>> 1 + +const res5 = await client.ts.info('thermometer:2'); +console.log(res5); +// >>> { rules: [], ... retentionTime: 100, ... +// STEP_END +// REMOVE_START +assert.equal(res4, 1); +assert.equal(res5.retentionTime, 100); +// REMOVE_END + +// STEP_START create_labels +const res6 = await client.ts.add('thermometer:3', 1, 10.4, { + LABELS: { location: 'UK', type: 'Mercury' } +}); +console.log(res6); // >>> 1 + +const res7 = await client.ts.info('thermometer:3'); +console.log(res7); +// >>> { labels: [{ name: 'location', value: 'UK' }, { name: 'type', value: 'Mercury' }], ... } +// STEP_END +// REMOVE_START +assert.equal(res6, 1); +assert.deepEqual(res7.labels, [ + { name: 'location', value: 'UK' }, + { name: 'type', value: 'Mercury' }, +]); +// REMOVE_END + +// STEP_START madd +// Recreate the thermometer series so this example runs on its own. +await client.del(['thermometer:1', 'thermometer:2']); +await client.ts.create('thermometer:1'); +await client.ts.add('thermometer:2', 1, 10.8); + +const res8 = await client.ts.mAdd([ + { key: 'thermometer:1', timestamp: 1, value: 9.2 }, + { key: 'thermometer:1', timestamp: 2, value: 9.9 }, + { key: 'thermometer:2', timestamp: 2, value: 10.3 } +]); +console.log(res8); // >>> [1, 2, 2] +// STEP_END +// REMOVE_START +assert.deepEqual(res8, [1, 2, 2]); +// REMOVE_END + +// STEP_START get +// Recreate the thermometer:2 series so this example runs on its own. +await client.del('thermometer:2'); +await client.ts.add('thermometer:2', 1, 10.8); +await client.ts.add('thermometer:2', 2, 10.3); +// The last recorded temperature for thermometer:2 +// was 10.3 at time 2. +const res9 = await client.ts.get('thermometer:2'); +console.log(res9); // >>> { timestamp: 2, value: 10.3 } +// STEP_END +// REMOVE_START +assert.equal(res9.timestamp, 2); +assert.equal(res9.value, 10.3); +// REMOVE_END + +// STEP_START range +// Add 5 data points to a time series named "rg:1". +const res10 = await client.ts.create('rg:1'); +console.log(res10); // >>> OK + +const res11 = await client.ts.mAdd([ + { key: 'rg:1', timestamp: 0, value: 18 }, + { key: 'rg:1', timestamp: 1, value: 14 }, + { key: 'rg:1', timestamp: 2, value: 22 }, + { key: 'rg:1', timestamp: 3, value: 18 }, + { key: 'rg:1', timestamp: 4, value: 24 } +]); +console.log(res11); // >>> [0, 1, 2, 3, 4] + +// Retrieve all the data points in ascending order. +const res12 = await client.ts.range('rg:1', '-', '+'); +console.log(res12); +// >>> [{ timestamp: 0, value: 18 }, { timestamp: 1, value: 14 }, ...] + +// Retrieve data points up to time 1 (inclusive). +const res13 = await client.ts.range('rg:1', '-', 1); +console.log(res13); +// >>> [{ timestamp: 0, value: 18 }, { timestamp: 1, value: 14 }] + +// Retrieve data points from time 3 onwards. +const res14 = await client.ts.range('rg:1', 3, '+'); +console.log(res14); +// >>> [{ timestamp: 3, value: 18 }, { timestamp: 4, value: 24 }] + +// Retrieve all the data points in descending order. +const res15 = await client.ts.revRange('rg:1', '-', '+'); +console.log(res15); +// >>> [{ timestamp: 4, value: 24 }, { timestamp: 3, value: 18 }, ...] + +// Retrieve data points up to time 1 (inclusive), but return them +// in descending order. +const res16 = await client.ts.revRange('rg:1', '-', 1); +console.log(res16); +// >>> [{ timestamp: 1, value: 14 }, { timestamp: 0, value: 18 }] +// STEP_END +// REMOVE_START +assert.equal(res10, 'OK'); +assert.deepEqual(res11, [0, 1, 2, 3, 4]); + +assert.deepEqual(res12, [ + { timestamp: 0, value: 18 }, + { timestamp: 1, value: 14 }, + { timestamp: 2, value: 22 }, + { timestamp: 3, value: 18 }, + { timestamp: 4, value: 24 } +]); +assert.deepEqual(res13, [ + { timestamp: 0, value: 18 }, + { timestamp: 1, value: 14 } +]); +assert.deepEqual(res14, [ + { timestamp: 3, value: 18 }, + { timestamp: 4, value: 24 } +]); +assert.deepEqual(res15, [ + { timestamp: 4, value: 24 }, + { timestamp: 3, value: 18 }, + { timestamp: 2, value: 22 }, + { timestamp: 1, value: 14 }, + { timestamp: 0, value: 18 } +]); +assert.deepEqual(res16, [ + { timestamp: 1, value: 14 }, + { timestamp: 0, value: 18 } +]); +// REMOVE_END + +// STEP_START range_filter +// Recreate the rg:1 series so this example runs on its own. +await client.del('rg:1'); +await client.ts.create('rg:1'); +await client.ts.mAdd([ + { key: 'rg:1', timestamp: 0, value: 18 }, + { key: 'rg:1', timestamp: 1, value: 14 }, + { key: 'rg:1', timestamp: 2, value: 22 }, + { key: 'rg:1', timestamp: 3, value: 18 }, + { key: 'rg:1', timestamp: 4, value: 24 } +]); + +const res17 = await client.ts.range('rg:1', '-', '+', { + FILTER_BY_TS: [0, 2, 4] +}); +console.log(res17); +// >>> [{ timestamp: 0, value: 18 }, { timestamp: 2, value: 22 }, { timestamp: 4, value: 24 }] + +const res18 = await client.ts.revRange('rg:1', '-', '+', { + FILTER_BY_TS: [0, 2, 4], + FILTER_BY_VALUE: { min: 20, max: 25 } +}); +console.log(res18); +// >>> [{ timestamp: 4, value: 24 }, { timestamp: 2, value: 22 }] + +const res19 = await client.ts.revRange('rg:1', '-', '+', { + FILTER_BY_TS: [0, 2, 4], + FILTER_BY_VALUE: { min: 22, max: 22 }, + COUNT: 1 +}); +console.log(res19); +// >>> [{ timestamp: 2, value: 22 }] +// STEP_END +// REMOVE_START +assert.deepEqual(res17, [ + { timestamp: 0, value: 18 }, + { timestamp: 2, value: 22 }, + { timestamp: 4, value: 24 } +]); +assert.deepEqual(res18, [ + { timestamp: 4, value: 24 }, + { timestamp: 2, value: 22 } +]); +assert.deepEqual(res19, [ + { timestamp: 2, value: 22 } +]); +// REMOVE_END + +// STEP_START query_multi +// Create three new "rg:" time series (two in the US +// and one in the UK, with different units) and add some +// data points. +const res20 = await client.ts.create('rg:2', { + LABELS: { location: 'us', unit: 'cm' } +}); +console.log(res20); // >>> OK + +const res21 = await client.ts.create('rg:3', { + LABELS: { location: 'us', unit: 'in' } +}); +console.log(res21); // >>> OK + +const res22 = await client.ts.create('rg:4', { + LABELS: { location: 'uk', unit: 'mm' } +}); +console.log(res22); // >>> OK + +const res23 = await client.ts.mAdd([ + { key: 'rg:2', timestamp: 0, value: 1.8 }, + { key: 'rg:3', timestamp: 0, value: 0.9 }, + { key: 'rg:4', timestamp: 0, value: 25 } +]); +console.log(res23); // >>> [0, 0, 0] + +const res24 = await client.ts.mAdd([ + { key: 'rg:2', timestamp: 1, value: 2.1 }, + { key: 'rg:3', timestamp: 1, value: 0.77 }, + { key: 'rg:4', timestamp: 1, value: 18 } +]); +console.log(res24); // >>> [1, 1, 1] + +const res25 = await client.ts.mAdd([ + { key: 'rg:2', timestamp: 2, value: 2.3 }, + { key: 'rg:3', timestamp: 2, value: 1.1 }, + { key: 'rg:4', timestamp: 2, value: 21 } +]); +console.log(res25); // >>> [2, 2, 2] + +const res26 = await client.ts.mAdd([ + { key: 'rg:2', timestamp: 3, value: 1.9 }, + { key: 'rg:3', timestamp: 3, value: 0.81 }, + { key: 'rg:4', timestamp: 3, value: 19 } +]); +console.log(res26); // >>> [3, 3, 3] + +const res27 = await client.ts.mAdd([ + { key: 'rg:2', timestamp: 4, value: 1.78 }, + { key: 'rg:3', timestamp: 4, value: 0.74 }, + { key: 'rg:4', timestamp: 4, value: 23 } +]); +console.log(res27); // >>> [4, 4, 4] + +// Retrieve the last data point from each US time series. +const res28 = await client.ts.mGet(['location=us']); +console.log(res28); +// >>> { "rg:2": { sample: { timestamp: 4, value: 1.78 } }, "rg:3": { sample: { timestamp: 4, value: 0.74 } } } + +// Retrieve the same data points, but include the `unit` +// label in the results. +const res29 = await client.ts.mGetSelectedLabels(['location=us'], ['unit']); +console.log(res29); +// >>> { "rg:2": { labels: { unit: 'cm' }, sample: { timestamp: 4, value: 1.78 } }, "rg:3": { labels: { unit: 'in' }, sample: { timestamp: 4, value: 0.74 } } } + +// Retrieve data points up to time 2 (inclusive) from all +// time series that use millimeters as the unit. Include all +// labels in the results. +const res30 = await client.ts.mRangeWithLabels('-', 2, 'unit=mm'); +console.log(res30); +// >>> { "rg:4": { labels: { location: 'uk', unit: 'mm' }, samples: [ +// { timestamp: 0, value: 25 }, +// { timestamp: 1, value: 18 }, +// { timestamp: 2, value: 21 } +// ] } } + +// Retrieve data points from time 1 to time 3 (inclusive) from +// all time series that use centimeters or millimeters as the unit, +// but only return the `location` label. Return the results +// in descending order of timestamp. +const res31 = await client.ts.mRevRangeSelectedLabels( + 1, 3, + ['location'], + ['unit=(cm,mm)'] +); +console.log(res31); +// >>> { "rg:2": { labels: { location: 'us' }, samples: [ +// { timestamp: 3, value: 1.9 }, +// { timestamp: 2, value: 2.3 }, +// { timestamp: 1, value: 2.1 } +// ] }, "rg:4": { labels: { location: 'uk' }, samples: [ +// { timestamp: 3, value: 19 }, +// { timestamp: 2, value: 21 }, +// { timestamp: 1, value: 18 } +// ] } } +// STEP_END +// REMOVE_START +assert.equal(res20, 'OK'); +assert.equal(res21, 'OK'); +assert.equal(res22, 'OK'); +assert.deepEqual(res23, [0, 0, 0]); +assert.deepEqual(res24, [1, 1, 1]); +assert.deepEqual(res25, [2, 2, 2]); +assert.deepEqual(res26, [3, 3, 3]); +assert.deepEqual(res27, [4, 4, 4]); + +assert.deepEqual(res28, { + "rg:2": { sample: { timestamp: 4, value: 1.78 } }, + "rg:3": { sample: { timestamp: 4, value: 0.74 } } +}); +assert.deepEqual(res29, { + "rg:2": { labels: { unit: 'cm' }, sample: { timestamp: 4, value: 1.78 } }, + "rg:3": { labels: { unit: 'in' }, sample: { timestamp: 4, value: 0.74 } } +}); + +assert.deepEqual(res30, { + "rg:4": { + labels: { location: 'uk', unit: 'mm' }, + samples: [ + { timestamp: 0, value: 25 }, + { timestamp: 1, value: 18 }, + { timestamp: 2, value: 21 } + ] + } +}); +assert.deepEqual(res31, { + "rg:2": { + labels: { location: 'us' }, + samples: [ + { timestamp: 3, value: 1.9 }, + { timestamp: 2, value: 2.3 }, + { timestamp: 1, value: 2.1 } + ] + }, + "rg:4": { + labels: { location: 'uk' }, + samples: [ + { timestamp: 3, value: 19 }, + { timestamp: 2, value: 21 }, + { timestamp: 1, value: 18 } + ] + } +}); +// REMOVE_END + +// STEP_START agg +// Recreate the rg:2 series so this example runs on its own. +await client.del('rg:2'); +await client.ts.create('rg:2'); +await client.ts.mAdd([ + { key: 'rg:2', timestamp: 0, value: 1.8 }, + { key: 'rg:2', timestamp: 1, value: 2.1 }, + { key: 'rg:2', timestamp: 2, value: 2.3 }, + { key: 'rg:2', timestamp: 3, value: 1.9 }, + { key: 'rg:2', timestamp: 4, value: 1.78 } +]); + +const res32 = await client.ts.range('rg:2', '-', '+', { + AGGREGATION: { + type: TIME_SERIES_AGGREGATION_TYPE.AVG, + timeBucket: 2 + } +}); +console.log(res32); +// >>> [{ timestamp: 0, value: 1.9500000000000002 },{ timestamp: 2, value: 2.0999999999999996 }, { timestamp: 4, value: 1.78 }] +// STEP_END +// REMOVE_START +assert.deepEqual(res32, [ + { timestamp: 0, value: 1.9500000000000002 }, + { timestamp: 2, value: 2.0999999999999996 }, + { timestamp: 4, value: 1.78 } +]); +// REMOVE_END + +// STEP_START agg_bucket +const res33 = await client.ts.create('sensor3'); +console.log(res33); // >>> OK + +const res34 = await client.ts.mAdd([ + { key: 'sensor3', timestamp: 10, value: 1000 }, + { key: 'sensor3', timestamp: 20, value: 2000 }, + { key: 'sensor3', timestamp: 30, value: 3000 }, + { key: 'sensor3', timestamp: 40, value: 4000 }, + { key: 'sensor3', timestamp: 50, value: 5000 }, + { key: 'sensor3', timestamp: 60, value: 6000 }, + { key: 'sensor3', timestamp: 70, value: 7000 } +]); +console.log(res34); // >>> [10, 20, 30, 40, 50, 60, 70] + +const res35 = await client.ts.range('sensor3', 10, 70, { + AGGREGATION: { + type: TIME_SERIES_AGGREGATION_TYPE.MIN, + timeBucket: 25 + } +}); +console.log(res35); +// >>> [{ timestamp: 0, value: 1000 }, { timestamp: 25, value: 3000 }, { timestamp: 50, value: 5000 }] +// STEP_END +// REMOVE_START +assert.equal(res33, 'OK'); +assert.deepEqual(res34, [10, 20, 30, 40, 50, 60, 70]); +assert.deepEqual(res35, [ + { timestamp: 0, value: 1000 }, + { timestamp: 25, value: 3000 }, + { timestamp: 50, value: 5000 } +]); +// REMOVE_END + +// STEP_START agg_align +// Recreate the sensor3 series so this example runs on its own. +await client.del('sensor3'); +await client.ts.create('sensor3'); +await client.ts.mAdd([ + { key: 'sensor3', timestamp: 10, value: 1000 }, + { key: 'sensor3', timestamp: 20, value: 2000 }, + { key: 'sensor3', timestamp: 30, value: 3000 }, + { key: 'sensor3', timestamp: 40, value: 4000 }, + { key: 'sensor3', timestamp: 50, value: 5000 }, + { key: 'sensor3', timestamp: 60, value: 6000 }, + { key: 'sensor3', timestamp: 70, value: 7000 } +]); + +const res36 = await client.ts.range('sensor3', 10, 70, { + AGGREGATION: { + type: TIME_SERIES_AGGREGATION_TYPE.MIN, + timeBucket: 25 + }, + ALIGN: 'START' +}); +console.log(res36); +// >>> [{ timestamp: 10, value: 1000 }, { timestamp: 35, value: 4000 }, { timestamp: 60, value: 6000 }] +// STEP_END +// REMOVE_START +assert.deepEqual(res36, [ + { timestamp: 10, value: 1000 }, + { timestamp: 35, value: 4000 }, + { timestamp: 60, value: 6000 } +]); +// REMOVE_END + +// STEP_START agg_multi +const res37 = await client.ts.create('wind:1', { + LABELS: { country: 'uk' } +}); +console.log(res37); // >>> OK + +const res38 = await client.ts.create('wind:2', { + LABELS: { country: 'uk' } +}); +console.log(res38); // >>> OK + +const res39 = await client.ts.create('wind:3', { + LABELS: { country: 'us' } +}); +console.log(res39); // >>> OK + +const res40 = await client.ts.create('wind:4', { + LABELS: { country: 'us' } +}); +console.log(res40); // >>> OK + +const res41 = await client.ts.mAdd([ + { key: 'wind:1', timestamp: 1, value: 12 }, + { key: 'wind:2', timestamp: 1, value: 18 }, + { key: 'wind:3', timestamp: 1, value: 5 }, + { key: 'wind:4', timestamp: 1, value: 20 } +]); +console.log(res41); // >>> [1, 1, 1, 1] + +const res42 = await client.ts.mAdd([ + { key: 'wind:1', timestamp: 2, value: 14 }, + { key: 'wind:2', timestamp: 2, value: 21 }, + { key: 'wind:3', timestamp: 2, value: 4 }, + { key: 'wind:4', timestamp: 2, value: 25 } +]); +console.log(res42); // >>> [2, 2, 2, 2] + +const res43 = await client.ts.mAdd([ + { key: 'wind:1', timestamp: 3, value: 10 }, + { key: 'wind:2', timestamp: 3, value: 24 }, + { key: 'wind:3', timestamp: 3, value: 8 }, + { key: 'wind:4', timestamp: 3, value: 18 } +]); +console.log(res43); // >>> [3, 3, 3, 3] + +// The result pairs contain the timestamp and the maximum sample value +// for the country at that timestamp. +const res44 = await client.ts.mRangeGroupBy( + '-', '+', ['country=(us,uk)'], + {label: 'country', REDUCE: TIME_SERIES_REDUCERS.MAX} +); +console.log(res44); +// >>> { "country=uk": { samples: [ +// { timestamp: 1, value: 18 }, +// { timestamp: 2, value: 21 }, +// { timestamp: 3, value: 24 } +// ] }, "country=us": { samples: [ +// { timestamp: 1, value: 20 }, +// { timestamp: 2, value: 25 }, +// { timestamp: 3, value: 18 } +// ] } } + +// The result pairs contain the timestamp and the average sample value +// for the country at that timestamp. +const res45 = await client.ts.mRangeGroupBy( + '-', '+', ['country=(us,uk)'], + { label: 'country', REDUCE: TIME_SERIES_REDUCERS.AVG} +); +console.log(res45); +// >>> { +// "country=uk": { +// samples: [{ timestamp: 1, value: 15 }, { timestamp: 2, value: 17.5 }, { timestamp: 3, value: 17 }] +// }, +// "country=us": { +// samples: [{ timestamp: 1, value: 12.5 }, { timestamp: 2, value: 14.5 }, { timestamp: 3, value: 13 }] +// } +// } +// STEP_END +// REMOVE_START +assert.equal(res37, 'OK'); +assert.equal(res38, 'OK'); +assert.equal(res39, 'OK'); +assert.equal(res40, 'OK'); +assert.deepEqual(res41, [1, 1, 1, 1]); +assert.deepEqual(res42, [2, 2, 2, 2]); +assert.deepEqual(res43, [3, 3, 3, 3]); + +assert.deepEqual(res44, { + "country=uk": { + samples: [ + { timestamp: 1, value: 18 }, + { timestamp: 2, value: 21 }, + { timestamp: 3, value: 24 } + ] + }, + "country=us": { + samples: [ + { timestamp: 1, value: 20 }, + { timestamp: 2, value: 25 }, + { timestamp: 3, value: 18 } + ] + } +}); +assert.deepEqual(res45, { + "country=uk": { + samples: [ + { timestamp: 1, value: 15 }, + { timestamp: 2, value: 17.5 }, + { timestamp: 3, value: 17 } + ] + }, + "country=us": { + samples: [ + { timestamp: 1, value: 12.5 }, + { timestamp: 2, value: 14.5 }, + { timestamp: 3, value: 13 } + ] + } +}); +// REMOVE_END + +// STEP_START create_compaction +const res46 = await client.ts.create('hyg:1'); +console.log(res46); // >>> OK + +const res47 = await client.ts.create('hyg:compacted'); +console.log(res47); // >>> OK + +const res48 = await client.ts.createRule('hyg:1', 'hyg:compacted', TIME_SERIES_AGGREGATION_TYPE.MIN, 3); +console.log(res48); // >>> OK + +const res49 = await client.ts.info('hyg:1'); +console.log(res49.rules); +// >>> [{ aggregationType: 'MIN', key: 'hyg:compacted', timeBucket: 3}] + +const res50 = await client.ts.info('hyg:compacted'); +console.log(res50.sourceKey); // >>> 'hyg:1' +// STEP_END +// REMOVE_START +assert.equal(res46, 'OK'); +assert.equal(res47, 'OK'); +assert.equal(res48, 'OK'); +assert.deepEqual(res49.rules, [ + { aggregationType: 'MIN', key: 'hyg:compacted', timeBucket: 3} +]); +assert.equal(res50.sourceKey, 'hyg:1'); +// REMOVE_END + +// STEP_START comp_add +// Recreate the compaction rule so this example runs on its own. +await client.del(['hyg:1', 'hyg:compacted']); +await client.ts.create('hyg:1'); +await client.ts.create('hyg:compacted'); +await client.ts.createRule('hyg:1', 'hyg:compacted', TIME_SERIES_AGGREGATION_TYPE.MIN, 3); + +const res51 = await client.ts.mAdd([ + { key: 'hyg:1', timestamp: 0, value: 75 }, + { key: 'hyg:1', timestamp: 1, value: 77 }, + { key: 'hyg:1', timestamp: 2, value: 78 } +]); +console.log(res51); // >>> [0, 1, 2] + +const res52 = await client.ts.range('hyg:compacted', '-', '+'); +console.log(res52); // >>> [] + +const res53 = await client.ts.add('hyg:1', 3, 79); +console.log(res53); // >>> 3 + +const res54 = await client.ts.range('hyg:compacted', '-', '+'); +console.log(res54); // >>> [{ timestamp: 0, value: 75 }] +// STEP_END +// REMOVE_START +assert.deepEqual(res51, [0, 1, 2]); +assert.deepEqual(res52, []); +assert.equal(res53, 3); +assert.deepEqual(res54, [{ timestamp: 0, value: 75 }]); +// REMOVE_END + +// STEP_START del +// Recreate the thermometer:1 series so this example runs on its own. +await client.del('thermometer:1'); +await client.ts.add('thermometer:1', 1, 9.2); +await client.ts.add('thermometer:1', 2, 9.9); + +const res55 = await client.ts.info('thermometer:1'); +console.log(res55.totalSamples); // >>> 2 +console.log(res55.firstTimestamp); // >>> 1 +console.log(res55.lastTimestamp); // >>> 2 + +const res56 = await client.ts.add('thermometer:1', 3, 9.7); +console.log(res56); // >>> 3 + +const res57 = await client.ts.info('thermometer:1'); +console.log(res57.totalSamples); // >>> 3 +console.log(res57.firstTimestamp); // >>> 1 +console.log(res57.lastTimestamp); // >>> 3 + +const res58 = await client.ts.del('thermometer:1', 1, 2); +console.log(res58); // >>> 2 + +const res59 = await client.ts.info('thermometer:1'); +console.log(res59.totalSamples); // >>> 1 +console.log(res59.firstTimestamp); // >>> 3 +console.log(res59.lastTimestamp); // >>> 3 + +const res60 = await client.ts.del('thermometer:1', 3, 3); +console.log(res60); // >>> 1 + +const res61 = await client.ts.info('thermometer:1'); +console.log(res61.totalSamples); // >>> 0 +// STEP_END +// REMOVE_START +assert.equal(res55.totalSamples, 2); +assert.equal(res55.firstTimestamp, 1); +assert.equal(res55.lastTimestamp, 2); +assert.equal(res56, 3); +assert.equal(res57.totalSamples, 3); +assert.equal(res57.firstTimestamp, 1); +assert.equal(res57.lastTimestamp, 3); +assert.equal(res58, 2); +assert.equal(res59.totalSamples, 1); +assert.equal(res59.firstTimestamp, 3); +assert.equal(res59.lastTimestamp, 3); +assert.equal(res60, 1); +assert.equal(res61.totalSamples, 0); +// REMOVE_END + +// HIDE_START +await client.quit(); +// HIDE_END \ No newline at end of file diff --git a/local_examples/time_series_tutorial/nredisstack/TimeSeriesTutorial.cs b/local_examples/time_series_tutorial/nredisstack/TimeSeriesTutorial.cs new file mode 100644 index 0000000000..aa89896399 --- /dev/null +++ b/local_examples/time_series_tutorial/nredisstack/TimeSeriesTutorial.cs @@ -0,0 +1,833 @@ +// EXAMPLE: time_series_tutorial +// HIDE_START +/* +Code samples for time series page: + https://redis.io/docs/latest/develop/data-types/timeseries/ +*/ +using NRedisStack.DataTypes; +using NRedisStack.Literals.Enums; +using StackExchange.Redis; +// HIDE_END + +// REMOVE_START +using NRedisStack.Tests; +using NRedisStack.RedisStackCommands; +using NRedisStack; + +namespace Doc; + +[Collection("DocsTests")] +// REMOVE_END + +public class TimeSeriesTutorial +// REMOVE_START +: AbstractNRedisStackTest, IDisposable +// REMOVE_END +{ + // REMOVE_START + public TimeSeriesTutorial(EndpointsFixture fixture) : base(fixture) { } + + [Fact] + // REMOVE_END + public void Run() + { + // REMOVE_START + // This is needed because we're constructing ConfigurationOptions in the test before calling GetConnection + SkipIfTargetConnectionDoesNotExist(EndpointsFixture.Env.Standalone); + var _ = GetCleanDatabase(EndpointsFixture.Env.Standalone); + // REMOVE_END + // HIDE_START + var muxer = ConnectionMultiplexer.Connect("localhost:6379"); + var db = muxer.GetDatabase(); + // HIDE_END + + // REMOVE_START + // Clear any keys here before using them in tests. + db.KeyDelete([ + "thermometer:1", "thermometer:2", "thermometer:3", + "rg:1", "rg:2", "rg:3", "rg:4", + "sensor3", + "wind:1", "wind:2", "wind:3", "wind:4", + "hyg:1", "hyg:compacted" + ]); + // REMOVE_END + + // STEP_START create + bool res1 = db.TS().Create( + "thermometer:1", + new TsCreateParamsBuilder().build() + ); + Console.WriteLine(res1); // >>> True + + RedisType res2 = db.KeyType("thermometer:1"); + Console.WriteLine(res2); // >>> TSDB-TYPE + + TimeSeriesInformation res3 = db.TS().Info("thermometer:1"); + Console.WriteLine(res3.TotalSamples); // >>> 0 + // STEP_END + // REMOVE_START + Assert.True(res1); + Assert.Equal(RedisType.Unknown, res2); + Assert.Equal(0, res3.TotalSamples); + // REMOVE_END + + // STEP_START create_retention + long res4 = db.TS().Add( + "thermometer:2", + new TsAddParamsBuilder() + .AddTimestamp(1) + .AddValue(10.8) + .AddRetentionTime(100) + .build() + ); + Console.WriteLine(res4); // >>> 1 + + TimeSeriesInformation res5 = db.TS().Info("thermometer:2"); + Console.WriteLine(res5.RetentionTime); + // >>> 100 + // STEP_END + // REMOVE_START + Assert.Equal(1, res4); + Assert.Equal(100, res5.RetentionTime); + // REMOVE_END + + // STEP_START create_labels + var labels = new List + { + new("location", "UK"), + new("type", "Mercury") + }; + long res6 = db.TS().Add( + "thermometer:3", + new TsAddParamsBuilder() + .AddTimestamp(1) + .AddValue(10.4) + .AddLabels(labels) + .build() + ); + Console.WriteLine(res6); // >>> 1 + + TimeSeriesInformation res7 = db.TS().Info("thermometer:3"); + Console.WriteLine( + $"Labels: {string.Join(", ", res7.Labels!.Select(l => $"{l.Key}={l.Value}"))}" + ); + // >>> Labels: location=UK, type=Mercury + // STEP_END + // REMOVE_START + Assert.Equal(1, res6); + Assert.Equal(2, res7.Labels!.Count); + Assert.Contains(res7.Labels, l => l.Key == "location" && l.Value == "UK"); + Assert.Contains(res7.Labels, l => l.Key == "type" && l.Value == "Mercury"); + // REMOVE_END + + // STEP_START madd + // Recreate the thermometer series so this example runs on its own. + db.KeyDelete(new RedisKey[] { "thermometer:1", "thermometer:2" }); + db.TS().Create("thermometer:1", new TsCreateParamsBuilder().build()); + db.TS().Add("thermometer:2", 1, 10.8); + + var sequence = new List<(string, TimeStamp, double)> + { + ("thermometer:1", 1, 9.2), + ("thermometer:1", 2, 9.9), + ("thermometer:2", 2, 10.3) + }; + IReadOnlyList res8 = db.TS().MAdd(sequence); + Console.WriteLine($"[{string.Join(", ", res8.Select(t => t.Value))}]"); + // >>> [1, 2, 2] + // STEP_END + // REMOVE_START + Assert.Equal(3, res8.Count); + Assert.Equal(1, (long)res8[0]); + Assert.Equal(2, (long)res8[1]); + Assert.Equal(2, (long)res8[2]); + // REMOVE_END + + // STEP_START get + // Recreate the thermometer:2 series so this example runs on its own. + db.KeyDelete("thermometer:2"); + db.TS().Add("thermometer:2", 1, 10.8); + db.TS().Add("thermometer:2", 2, 10.3); + // The last recorded temperature for thermometer:2 + // was 10.3 at time 2. + TimeSeriesTuple? res9 = db.TS().Get("thermometer:2"); + Console.WriteLine($"({res9!.Time.Value}, {res9.Val})"); + // >>> (2, 10.3) + // STEP_END + // REMOVE_START + Assert.NotNull(res9); + Assert.Equal(2, (long)res9.Time); + Assert.Equal(10.3, res9.Val); + // REMOVE_END + + // STEP_START range + // Add 5 data points to a time series named "rg:1". + bool res10 = db.TS().Create( + "rg:1", + new TsCreateParamsBuilder().build() + ); + Console.WriteLine(res10); // >>> True + + var sequence2 = new List<(string, TimeStamp, double)> + { + ("rg:1", 0, 18), + ("rg:1", 1, 14), + ("rg:1", 2, 22), + ("rg:1", 3, 18), + ("rg:1", 4, 24) + }; + IReadOnlyList res11 = db.TS().MAdd(sequence2); + Console.WriteLine( + $"[{string.Join(", ", res11.Select(t => t.Value))}]" + ); + // >>> [0, 1, 2, 3, 4] + + // Retrieve all the data points in ascending order. + IReadOnlyList res12 = db.TS().Range("rg:1", "-", "+"); + Console.WriteLine( + $"[{string.Join(", ", res12.Select(t => $"({t.Time.Value}, {t.Val})"))}]" + ); + // >>> [(0, 18), (1, 14), (2, 22), (3, 18), (4, 24)] + + // Retrieve data points up to time 1 (inclusive). + IReadOnlyList res13 = db.TS().Range("rg:1", "-", 1); + Console.WriteLine( + $"[{string.Join(", ", res13.Select(t => $"({t.Time.Value}, {t.Val})"))}]" + ); + // >>> [(0, 18), (1, 14)] + + // Retrieve data points from time 3 onwards. + IReadOnlyList res14 = db.TS().Range("rg:1", 3, "+"); + Console.WriteLine( + $"[{string.Join(", ", res14.Select(t => $"({t.Time.Value}, {t.Val})"))}]" + ); + // >>> [(3, 18), (4, 24)] + + // Retrieve all the data points in descending order. + IReadOnlyList res15 = db.TS().RevRange("rg:1", "-", "+"); + Console.WriteLine( + $"[{string.Join(", ", res15.Select(t => $"({t.Time.Value}, {t.Val})"))}]" + ); + // >>> [(4, 24), (3, 18), (2, 22), (1, 14), (0, 18)] + + // Retrieve data points up to time 1 (inclusive), but return them + // in descending order. + IReadOnlyList res16 = db.TS().RevRange("rg:1", "-", 1); + Console.WriteLine( + $"[{string.Join(", ", res16.Select(t => $"({t.Time.Value}, {t.Val})"))}]" + ); + // >>> [(1, 14), (0, 18)] + // STEP_END + // REMOVE_START + Assert.True(res10); + Assert.Equal(5, res11.Count); + Assert.Equal(5, res12.Count); + Assert.Equal(2, res13.Count); + Assert.Equal(2, res14.Count); + Assert.Equal(5, res15.Count); + Assert.Equal(2, res16.Count); + // REMOVE_END + + // STEP_START range_filter + // Recreate the rg:1 series so this example runs on its own. + db.KeyDelete("rg:1"); + db.TS().Create("rg:1", new TsCreateParamsBuilder().build()); + db.TS().MAdd(new List<(string, TimeStamp, double)> + { + ("rg:1", 0, 18), + ("rg:1", 1, 14), + ("rg:1", 2, 22), + ("rg:1", 3, 18), + ("rg:1", 4, 24) + }); + + var filterByTs = new List { 0, 2, 4 }; + IReadOnlyList res17 = db.TS().Range( + "rg:1", "-", "+", filterByTs: filterByTs + ); + Console.WriteLine( + $"[{string.Join(", ", res17.Select(t => $"({t.Time.Value}, {t.Val})"))}]" + ); + // >>> [(0, 18), (2, 22), (4, 24)] + + IReadOnlyList res18 = db.TS().RevRange( + "rg:1", "-", "+", + filterByTs: filterByTs, + filterByValue: (20, 25) + ); + Console.WriteLine( + $"[{string.Join(", ", res18.Select(t => $"({t.Time.Value}, {t.Val})"))}]" + ); + // >>> [(4, 24), (2, 22)] + + IReadOnlyList res19 = db.TS().RevRange( + "rg:1", "-", "+", + filterByTs: filterByTs, + filterByValue: (22, 22), + count: 1 + ); + Console.WriteLine( + $"[{string.Join(", ", res19.Select(t => $"({t.Time.Value}, {t.Val})"))}]" + ); + // >>> [(2, 22)] + // STEP_END + // REMOVE_START + Assert.Equal(3, res17.Count); + Assert.Equal(2, res18.Count); + Assert.Single(res19); + // REMOVE_END + + // STEP_START query_multi + // Create three new "rg:" time series (two in the US + // and one in the UK, with different units) and add some + // data points. + var labels2 = new List + { + new("location", "us"), + new("unit", "cm") + }; + bool res20 = db.TS().Create( + "rg:2", + new TsCreateParamsBuilder() + .AddLabels(labels2) + .build() + ); + Console.WriteLine(res20); // >>> True + + var labels3 = new List + { + new("location", "us"), + new("unit", "in") + }; + + bool res21 = db.TS().Create( + "rg:3", + new TsCreateParamsBuilder() + .AddLabels(labels3) + .build() + ); + Console.WriteLine(res21); // >>> True + + var labels4 = new List + { + new("location", "uk"), + new("unit", "mm") + }; + bool res22 = db.TS().Create( + "rg:4", + new TsCreateParamsBuilder() + .AddLabels(labels4) + .build() + ); + Console.WriteLine(res22); // >>> True + + var sequence3 = new List<(string, TimeStamp, double)> + { + ("rg:2", 0, 1.8), + ("rg:3", 0, 0.9), + ("rg:4", 0, 25) + }; + IReadOnlyList res23 = db.TS().MAdd(sequence3); + Console.WriteLine( + $"[{string.Join(", ", res23.Select(t => t.Value))}]" + ); + // >>> [0, 0, 0] + + var sequence4 = new List<(string, TimeStamp, double)> + { + ("rg:2", 1, 2.1), + ("rg:3", 1, 0.77), + ("rg:4", 1, 18) + }; + + IReadOnlyList res24 = db.TS().MAdd(sequence4); + Console.WriteLine( + $"[{string.Join(", ", res24.Select(t => t.Value))}]" + ); + // >>> [1, 1, 1] + + var sequence5 = new List<(string, TimeStamp, double)> + { + ("rg:2", 2, 2.3), + ("rg:3", 2, 1.1), + ("rg:4", 2, 21) + }; + + IReadOnlyList res25 = db.TS().MAdd(sequence5); + Console.WriteLine( + $"[{string.Join(", ", res25.Select(t => t.Value))}]" + ); + // >>> [2, 2, 2] + + var sequence6 = new List<(string, TimeStamp, double)> + { + ("rg:2", 3, 1.9), + ("rg:3", 3, 0.81), + ("rg:4", 3, 19) + }; + + IReadOnlyList res26 = db.TS().MAdd(sequence6); + Console.WriteLine( + $"[{string.Join(", ", res26.Select(t => t.Value))}]" + ); + // >>> [3, 3, 3] + + var sequence7 = new List<(string, TimeStamp, double)> + { + ("rg:2", 4, 1.78), + ("rg:3", 4, 0.74), + ("rg:4", 4, 23) + }; + IReadOnlyList res27 = db.TS().MAdd(sequence7); + Console.WriteLine( + $"[{string.Join(", ", res27.Select(t => t.Value))}]" + ); + // >>> [4, 4, 4] + + // Retrieve the last data point from each US time series. If + // you don't specify any labels, an empty array is returned + // for the labels. + var filters = new List { "location=us" }; + var res28 = db.TS().MGet(filters); + Console.WriteLine(res28.Count); // >>> 2 + + foreach (var (key, labelsResult, value) in res28) + { + Console.WriteLine($"{key}: ({value.Time.Value}, {value.Val})"); + } + // >>> rg:2: (4, 1.78) + // >>> rg:3: (4, 0.74) + + // Retrieve the same data points, but include the `unit` + // label in the results. + var selectUnitLabel = new List { "unit" }; + + var res29 = db.TS().MGet( + filters, + selectedLabels: selectUnitLabel + ); + Console.WriteLine(res29.Count); // >>> 2 + + foreach (var (key, labelsResult, value) in res29) + { + var unitLabel = labelsResult.FirstOrDefault(l => l.Key == "unit"); + Console.WriteLine($"{key} (unit: {unitLabel?.Value}): ({value.Time.Value}, {value.Val})"); + } + // >>> rg:2 (unit: cm): (4, 1.78) + // >>> rg:3 (unit: in): (4, 0.74) + + // Retrieve data points up to time 2 (inclusive) from all + // time series that use millimeters as the unit. Include all + // labels in the results. + var mmFilters = new List { "unit=mm" }; + + IReadOnlyList< + (string, IReadOnlyList, IReadOnlyList) + > res30 = db.TS().MRange( + "-", 2, mmFilters, withLabels: true + ); + Console.WriteLine(res30.Count); // >>> 1 + + foreach (var (key, labelsResult, values) in res30) + { + Console.WriteLine($"{key}:"); + Console.WriteLine($" Labels: ({string.Join(", ", labelsResult.Select(l => $"{l.Key}={l.Value}"))})"); + Console.WriteLine($" Values: [{string.Join(", ", values.Select(t => $"({t.Time.Value}, {t.Val})"))}]"); + } + // >>> rg:4: + // >>> Labels:location=uk,unit=mm + // >>> Values: [(1, 23), (2, 21), (3, 19)] + + // Retrieve data points from time 1 to time 3 (inclusive) from + // all time series that use centimeters or millimeters as the unit, + // but only return the `location` label. Return the results + // in descending order of timestamp. + var cmMmFilters = new List { "unit=(cm,mm)" }; + var locationLabels = new List { "location" }; + IReadOnlyList< + (string, IReadOnlyList, IReadOnlyList) + > res31 = db.TS().MRevRange( + 1, 3, cmMmFilters, selectLabels: locationLabels + ); + Console.WriteLine(res31.Count); // >>> 2 + + foreach (var (key, labelsResult, values) in res31) + { + var locationLabel = labelsResult.FirstOrDefault(l => l.Key == "location"); + Console.WriteLine($"{key} (location: {locationLabel?.Value})"); + Console.WriteLine($" Values: [{string.Join(", ", values.Select(t => $"({t.Time.Value}, {t.Val})"))}]"); + } + // >>> rg:4 (location: uk) + // >>> Values: [(3, 19), (2, 21), (1, 23)] + // >>> rg:2 (location: us) + // >>> Values: [(3, 2.3), (2, 2.1), (1, 1.8)] + // STEP_END + // REMOVE_START + Assert.True(res20); + Assert.True(res21); + Assert.True(res22); + Assert.Equal(3, res23.Count); + Assert.Equal(3, res24.Count); + Assert.Equal(3, res25.Count); + Assert.Equal(3, res26.Count); + Assert.Equal(3, res27.Count); + Assert.Equal(2, res28.Count); + Assert.Equal(2, res29.Count); + Assert.Single(res30); + Assert.Equal(2, res31.Count); + // REMOVE_END + + // STEP_START agg + // Recreate the rg:2 series so this example runs on its own. + db.KeyDelete("rg:2"); + db.TS().Create("rg:2", new TsCreateParamsBuilder().build()); + db.TS().MAdd(new List<(string, TimeStamp, double)> + { + ("rg:2", 0, 1.8), + ("rg:2", 1, 2.1), + ("rg:2", 2, 2.3), + ("rg:2", 3, 1.9), + ("rg:2", 4, 1.78) + }); + + IReadOnlyList res32 = db.TS().Range( + "rg:2", "-", "+", + aggregation: TsAggregation.Avg, + timeBucket: 2 + ); + Console.WriteLine($"[{string.Join(", ", res32.Select(t => $"({t.Time.Value}, {t.Val})"))}]"); + // >>> [(0, 1.95), (2, 2.1), (4, 1.78)] + // STEP_END + // REMOVE_START + Assert.Equal(3, res32.Count); + // REMOVE_END + + // STEP_START agg_bucket + bool res33 = db.TS().Create( + "sensor3", + new TsCreateParamsBuilder() + .build() + ); + Console.WriteLine(res33); // >>> True + + var sensorSequence = new List<(string, TimeStamp, double)> + { + ("sensor3", 10, 1000), + ("sensor3", 20, 2000), + ("sensor3", 30, 3000), + ("sensor3", 40, 4000), + ("sensor3", 50, 5000), + ("sensor3", 60, 6000), + ("sensor3", 70, 7000) + }; + IReadOnlyList res34 = db.TS().MAdd(sensorSequence); + Console.WriteLine($"[{string.Join(", ", res34.Select(t => t.Value))}]"); + // >>> [10, 20, 30, 40, 50, 60, 70] + + IReadOnlyList res35 = db.TS().Range( + "sensor3", 10, 70, + aggregation: TsAggregation.Min, + timeBucket: 25 + ); + Console.WriteLine($"[{string.Join(", ", res35.Select(t => $"({t.Time.Value}, {t.Val})"))}]"); + // >>> [(0, 1000), (25, 3000), (50, 5000)] + // STEP_END + // REMOVE_START + Assert.True(res33); + Assert.Equal(7, res34.Count); + Assert.Equal(3, res35.Count); + // REMOVE_END + + // STEP_START agg_align + // Recreate the sensor3 series so this example runs on its own. + db.KeyDelete("sensor3"); + db.TS().Create("sensor3", new TsCreateParamsBuilder().build()); + db.TS().MAdd(new List<(string, TimeStamp, double)> + { + ("sensor3", 10, 1000), + ("sensor3", 20, 2000), + ("sensor3", 30, 3000), + ("sensor3", 40, 4000), + ("sensor3", 50, 5000), + ("sensor3", 60, 6000), + ("sensor3", 70, 7000) + }); + + IReadOnlyList res36 = db.TS().Range( + "sensor3", 10, 70, + aggregation: TsAggregation.Min, + timeBucket: 25, + align: "-" + ); + Console.WriteLine( + $"[{string.Join(", ", res36.Select(t => $"({t.Time.Value}, {t.Val})"))}]" + ); + // >>> [(10, 1000), (35, 4000), (60, 6000)] + // STEP_END + // REMOVE_START + Assert.Equal(3, res36.Count); + // REMOVE_END + + // STEP_START agg_multi + var ukLabels = new List { new("country", "uk") }; + + bool res37 = db.TS().Create( + "wind:1", + new TsCreateParamsBuilder() + .AddLabels(ukLabels) + .build() + ); + Console.WriteLine(res37); // >>> True + + bool res38 = db.TS().Create( + "wind:2", + new TsCreateParamsBuilder() + .AddLabels(ukLabels) + .build() + ); + Console.WriteLine(res38); // >>> True + + var usLabels = new List { new("country", "us") }; + bool res39 = db.TS().Create( + "wind:3", + new TsCreateParamsBuilder() + .AddLabels(usLabels) + .build() + ); + Console.WriteLine(res39); // >>> True + + bool res40 = db.TS().Create( + "wind:4", + new TsCreateParamsBuilder() + .AddLabels(usLabels) + .build() + ); + Console.WriteLine(res40); // >>> True + + var windSequence1 = new List<(string, TimeStamp, double)> + { + ("wind:1", 1, 12), + ("wind:2", 1, 18), + ("wind:3", 1, 5), + ("wind:4", 1, 20) + }; + IReadOnlyList res41 = db.TS().MAdd(windSequence1); + Console.WriteLine( + $"[{string.Join(", ", res41.Select(t => t.Value))}]" + ); + // >>> [1, 1, 1, 1] + + var windSequence2 = new List<(string, TimeStamp, double)> + { + ("wind:1", 2, 14), + ("wind:2", 2, 21), + ("wind:3", 2, 4), + ("wind:4", 2, 25) + }; + IReadOnlyList res42 = db.TS().MAdd(windSequence2); + Console.WriteLine( + $"[{string.Join(", ", res42.Select(t => t.Value))}]" + ); + // >>> [2, 2, 2, 2] + + var windSequence3 = new List<(string, TimeStamp, double)> + { + ("wind:1", 3, 10), + ("wind:2", 3, 24), + ("wind:3", 3, 8), + ("wind:4", 3, 18) + }; + IReadOnlyList res43 = db.TS().MAdd(windSequence3); + Console.WriteLine( + $"[{string.Join(", ", res43.Select(t => t.Value))}]" + ); + // >>> [3, 3, 3, 3] + + // The result pairs contain the timestamp and the maximum sample value + // for the country at that timestamp. + var countryFilters = new List { "country=(us,uk)" }; + IReadOnlyList< + (string, IReadOnlyList, IReadOnlyList) + > res44 = db.TS().MRange( + "-", "+", + countryFilters, + groupbyTuple: ("country", TsReduce.Max) + ); + Console.WriteLine(res44.Count); // >>> 2 + + foreach (var (key, labelsResult, values) in res44) + { + Console.WriteLine($"{key}:"); + Console.WriteLine($" Values: [{string.Join(", ", values.Select(t => $"({t.Time.Value}, {t.Val})"))}]"); + } + // >>> country=uk + // >>> Values: [(1, 18), (2, 21), (3, 24)] + // >>> country=us + // >>> Values: [(1, 20), (2, 25), (3, 18)] + + // The result pairs contain the timestamp and the average sample value + // for the country at that timestamp. + IReadOnlyList< + (string, IReadOnlyList, IReadOnlyList) + > res45 = db.TS().MRange( + "-", "+", + countryFilters, + groupbyTuple: ("country", TsReduce.Avg) + ); + Console.WriteLine(res45.Count); // >>> 2 + + foreach (var (key, labelsResult, values) in res45) + { + Console.WriteLine($"{key}:"); + Console.WriteLine($" Values: [{string.Join(", ", values.Select(t => $"({t.Time.Value}, {t.Val})"))}]"); + } + // >>> country=uk + // >>> Values: [(1, 14), (2, 18), (3, 10)] + // >>> country=us + // >>> Values: [(1, 16), (2, 22), (3, 14)] + // STEP_END + // REMOVE_START + Assert.True(res37); + Assert.True(res38); + Assert.True(res39); + Assert.True(res40); + Assert.Equal(4, res41.Count); + Assert.Equal(4, res42.Count); + Assert.Equal(4, res43.Count); + Assert.Equal(2, res44.Count); + Assert.Equal(2, res45.Count); + // REMOVE_END + + // STEP_START create_compaction + bool res46 = db.TS().Create( + "hyg:1", + new TsCreateParamsBuilder().build() + ); + Console.WriteLine(res46); // >>> True + + bool res47 = db.TS().Create( + "hyg:compacted", + new TsCreateParamsBuilder().build() + ); + Console.WriteLine(res47); // >>> True + + var compactionRule = new TimeSeriesRule("hyg:compacted", 3, TsAggregation.Min); + bool res48 = db.TS().CreateRule("hyg:1", compactionRule); + Console.WriteLine(res48); // >>> True + + TimeSeriesInformation res49 = db.TS().Info("hyg:1"); + Console.WriteLine(res49.Rules!.Count); + // >>> 1 + + TimeSeriesInformation res50 = db.TS().Info("hyg:compacted"); + Console.WriteLine(res50.SourceKey); + // >>> hyg:1 + // STEP_END + // REMOVE_START + Assert.True(res46); + Assert.True(res47); + Assert.True(res48); + Assert.Single(res49.Rules!); + Assert.Equal("hyg:1", res50.SourceKey); + // REMOVE_END + + // STEP_START comp_add + // Recreate the compaction rule so this example runs on its own. + db.KeyDelete(new RedisKey[] { "hyg:1", "hyg:compacted" }); + db.TS().Create("hyg:1", new TsCreateParamsBuilder().build()); + db.TS().Create("hyg:compacted", new TsCreateParamsBuilder().build()); + db.TS().CreateRule("hyg:1", new TimeSeriesRule("hyg:compacted", 3, TsAggregation.Min)); + + var hygSequence1 = new List<(string, TimeStamp, double)> + { + ("hyg:1", 0, 75), + ("hyg:1", 1, 77), + ("hyg:1", 2, 78) + }; + IReadOnlyList res51 = db.TS().MAdd(hygSequence1); + Console.WriteLine($"[{string.Join(", ", res51.Select(t => t.Value))}]"); + // >>> [0, 1, 2] + + IReadOnlyList res52 = db.TS().Range("hyg:compacted", "-", "+"); + Console.WriteLine(res52.Count); // >>> 0 + + TimeStamp res53 = db.TS().Add( + "hyg:1", + new TsAddParamsBuilder() + .AddTimestamp(3) + .AddValue(79) + .build() + ); + Console.WriteLine(res53.Value); // >>> 3 + + IReadOnlyList res54 = db.TS().Range("hyg:compacted", "-", "+"); + Console.WriteLine( + $"[{string.Join(", ", res54.Select(t => $"({t.Time.Value}, {t.Val})"))}]" + ); + // >>> [(0, 75)] + // STEP_END + // REMOVE_START + Assert.Equal(3, res51.Count); + Assert.Empty(res52); + Assert.Equal(3, (long)res53); + Assert.Single(res54); + // REMOVE_END + + // STEP_START del + // Recreate the thermometer:1 series so this example runs on its own. + db.KeyDelete("thermometer:1"); + db.TS().Add("thermometer:1", 1, 9.2); + db.TS().Add("thermometer:1", 2, 9.9); + + TimeSeriesInformation res55 = db.TS().Info("thermometer:1"); + Console.WriteLine(res55.TotalSamples); // >>> 2 + Console.WriteLine(res55.FirstTimeStamp!); // >>> 1 + Console.WriteLine(res55.LastTimeStamp!); // >>> 2 + + TimeStamp res56 = db.TS().Add( + "thermometer:1", + new TsAddParamsBuilder() + .AddTimestamp(3) + .AddValue(9.7) + .build() + ); + Console.WriteLine(res56.Value); // >>> 3 + + TimeSeriesInformation res57 = db.TS().Info("thermometer:1"); + Console.WriteLine(res57.TotalSamples); // >>> 3 + Console.WriteLine(res57.FirstTimeStamp!); // >>> 1 + Console.WriteLine(res57.LastTimeStamp!); // >>> 3 + + long res58 = db.TS().Del("thermometer:1", 1, 2); + Console.WriteLine(res58); // >>> 2 + + TimeSeriesInformation res59 = db.TS().Info("thermometer:1"); + Console.WriteLine(res59.TotalSamples); // >>> 1 + Console.WriteLine(res59.FirstTimeStamp!); // >>> 3 + Console.WriteLine(res59.LastTimeStamp!); // >>> 3 + + long res60 = db.TS().Del("thermometer:1", 3, 3); + Console.WriteLine(res60); // >>> 1 + + TimeSeriesInformation res61 = db.TS().Info("thermometer:1"); + Console.WriteLine(res61.TotalSamples); // >>> 0 + // STEP_END + // REMOVE_START + Assert.Equal(2, res55.TotalSamples); + Assert.Equal(1, (long)res55.FirstTimeStamp!); + Assert.Equal(2, (long)res55.LastTimeStamp!); + Assert.Equal(3, (long)res56); + Assert.Equal(3, res57.TotalSamples); + Assert.Equal(1, (long)res57.FirstTimeStamp!); + Assert.Equal(3, (long)res57.LastTimeStamp!); + Assert.Equal(2, res58); + Assert.Equal(1, res59.TotalSamples); + Assert.Equal(3, (long)res59.FirstTimeStamp!); + Assert.Equal(3, (long)res59.LastTimeStamp!); + Assert.Equal(1, res60); + Assert.Equal(0, res61.TotalSamples); + // REMOVE_END + // HIDE_START + } +} +// HIDE_END From 12302f97d53fdb1f935a45f15c8e5a45ce403168 Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Wed, 8 Jul 2026 16:17:33 +0100 Subject: [PATCH 08/28] DOC-6823 make sets smismember example runnable in the interactive CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes try_it="false" from the smismember example and prepends a recreate (DEL bikes:racing:france + SADD bike:1 bike:2 bike:3) so it runs standalone. Validated via redis-cli: SADD->3, SISMEMBER bike:1->1, SMISMEMBER bike:2/3/4->1,1,0. Client-tab parity (10 local clients + a lettuce-async override) still to follow. Directive: don't "fix" the leading DEL count — cosmetic cold-sandbox 0-vs-1, the accepted diff established by zrank Ticket: DOC-6823 Co-Authored-By: Claude Opus 4.8 (1M context) --- content/develop/data-types/sets.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/content/develop/data-types/sets.md b/content/develop/data-types/sets.md index 7f5e0b12f7..a16fb506f3 100644 --- a/content/develop/data-types/sets.md +++ b/content/develop/data-types/sets.md @@ -89,7 +89,12 @@ elements in any order at every call. Redis has commands to test for set membership. These commands can be used on single as well as multiple items: -{{< clients-example set="sets_tutorial" step="smismember" description="Batch membership checks: Test multiple items at once using SMISMEMBER when you need to reduce round trips to the server" difficulty="intermediate" buildsUpon="sismember" try_it="false" >}} +{{< clients-example set="sets_tutorial" step="smismember" description="Batch membership checks: Test multiple items at once using SMISMEMBER when you need to reduce round trips to the server" difficulty="intermediate" buildsUpon="sismember" >}} +# Recreate the France racing set so this example runs on its own. +> DEL bikes:racing:france +(integer) 1 +> SADD bikes:racing:france bike:1 bike:2 bike:3 +(integer) 3 > SISMEMBER bikes:racing:france bike:1 (integer) 1 > SMISMEMBER bikes:racing:france bike:2 bike:3 bike:4 From 9bf49b2f4a6591a34f8abd3c835e98e266701dfa Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Wed, 8 Jul 2026 16:34:37 +0100 Subject: [PATCH 09/28] DOC-6823 add smismember recreates across the sets client tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the ten sets client tabs into line with the CLI snippet by adding the DEL + SADD recreate to each smismember step, so the "Try it" button and any isolated single-step run land in the {bike:1, bike:2, bike:3} state the reads expect. Go was left untouched: its per-function ExampleClient_smismember already recreates the set outside the STEP markers, which is the established Go pattern. The Lettuce async tab had no local override at all (it rendered straight from the upstream redis/lettuce source), so it is created here as a faithful 1:1 async translation of the reactive override. All eleven clients pass the example-test-harness, which now carries a sets_tutorial block. Learned: the reactive recreate cannot live inside the shared Mono.when() with the reads — a DEL there races the sibling reads on the same key, so it is blocked sequentially first; async thenCompose chains are ordered, so the recreate sits safely inline Constraint: keep the Lettuce-reactive smismember recreate out of Mono.when() and .block() it before the reads Directive: local_examples/tmp/lettuce-async/SetExample.java is a docs-only override with no upstream counterpart — push it to redis/lettuce so their CI covers it Ticket: DOC-6823 Co-Authored-By: Claude Opus 4.8 (1M context) --- build/example-test-harness/run.sh | 11 + local_examples/php/DtSetsTest.php | 4 + local_examples/ruby/dt_sets.rb | 4 + local_examples/rust-async/dt-sets.rs | 7 + local_examples/rust-sync/dt-sets.rs | 6 + .../tmp/datatypes/sets/SetsExample.java | 4 + .../tmp/datatypes/sets/SetsTutorial.cs | 4 + local_examples/tmp/datatypes/sets/dt-set.js | 4 + local_examples/tmp/datatypes/sets/dt_set.py | 4 + .../tmp/lettuce-async/SetExample.java | 226 ++++++++++++++++++ .../tmp/lettuce-reactive/SetExample.java | 7 + 11 files changed, 281 insertions(+) create mode 100644 local_examples/tmp/lettuce-async/SetExample.java diff --git a/build/example-test-harness/run.sh b/build/example-test-harness/run.sh index 2c98fc4aa2..6b6a92dead 100755 --- a/build/example-test-harness/run.sh +++ b/build/example-test-harness/run.sh @@ -55,6 +55,17 @@ src_path() { hash_tutorial:lettuce-reactive)echo local_examples/tmp/lettuce-reactive/HashExample.java ;; hash_tutorial:php) echo local_examples/php/DtHashTest.php ;; hash_tutorial:dotnet) echo local_examples/tmp/datatypes/hashes/HashExample.cs ;; + sets_tutorial:python) echo local_examples/tmp/datatypes/sets/dt_set.py ;; + sets_tutorial:node) echo local_examples/tmp/datatypes/sets/dt-set.js ;; + sets_tutorial:go) echo local_examples/tmp/datatypes/sets/sets_example_test.go ;; + sets_tutorial:jedis) echo local_examples/tmp/datatypes/sets/SetsExample.java ;; + sets_tutorial:ruby) echo local_examples/ruby/dt_sets.rb ;; + sets_tutorial:rust-sync) echo local_examples/rust-sync/dt-sets.rs ;; + sets_tutorial:rust-async) echo local_examples/rust-async/dt-sets.rs ;; + sets_tutorial:lettuce-async) echo local_examples/tmp/lettuce-async/SetExample.java ;; + sets_tutorial:lettuce-reactive)echo local_examples/tmp/lettuce-reactive/SetExample.java ;; + sets_tutorial:php) echo local_examples/php/DtSetsTest.php ;; + sets_tutorial:dotnet) echo local_examples/tmp/datatypes/sets/SetsTutorial.cs ;; time_series_tutorial:python) echo local_examples/time_series_tutorial/redis-py/dt_time_series.py ;; time_series_tutorial:go) echo local_examples/time_series_tutorial/go-redis/timeseries_tut_test.go ;; time_series_tutorial:jedis) echo local_examples/time_series_tutorial/jedis/TimeSeriesTutorialExample.java ;; diff --git a/local_examples/php/DtSetsTest.php b/local_examples/php/DtSetsTest.php index 5444c84eb3..cdfa1aa26f 100644 --- a/local_examples/php/DtSetsTest.php +++ b/local_examples/php/DtSetsTest.php @@ -98,6 +98,10 @@ public function testDtSet() { // REMOVE_END // STEP_START smismember + // Recreate the set so this example runs on its own. + $r->del('bikes:racing:france'); + $r->sadd('bikes:racing:france', 'bike:1', 'bike:2', 'bike:3'); + $res11 = $r->sismember('bikes:racing:france', 'bike:1'); echo $res11 . PHP_EOL; // >>> 1 diff --git a/local_examples/ruby/dt_sets.rb b/local_examples/ruby/dt_sets.rb index 238c5f754f..fb8b8bb118 100644 --- a/local_examples/ruby/dt_sets.rb +++ b/local_examples/ruby/dt_sets.rb @@ -99,6 +99,10 @@ def assert_same_members(expected, actual) # REMOVE_END # STEP_START smismember +# Recreate the set so this example runs on its own. +r.del('bikes:racing:france') +r.sadd('bikes:racing:france', ['bike:1', 'bike:2', 'bike:3']) + res11 = r.sismember('bikes:racing:france', 'bike:1') puts res11 # true diff --git a/local_examples/rust-async/dt-sets.rs b/local_examples/rust-async/dt-sets.rs index 20903285e6..75381d191c 100644 --- a/local_examples/rust-async/dt-sets.rs +++ b/local_examples/rust-async/dt-sets.rs @@ -133,6 +133,13 @@ mod tests { // STEP_END // STEP_START smismember + // Recreate the set so this example runs on its own. + let _: usize = r.del("bikes:racing:france").await.unwrap_or(0); + let _: usize = r + .sadd("bikes:racing:france", &["bike:1", "bike:2", "bike:3"]) + .await + .unwrap_or(0); + if let Ok(res) = r.sismember("bikes:racing:france", "bike:1").await { let res: bool = res; println!("{}", i32::from(res)); // >>> 1 diff --git a/local_examples/rust-sync/dt-sets.rs b/local_examples/rust-sync/dt-sets.rs index cb6f3fa7f3..19cb9a9932 100644 --- a/local_examples/rust-sync/dt-sets.rs +++ b/local_examples/rust-sync/dt-sets.rs @@ -133,6 +133,12 @@ mod sets_tests { // STEP_END // STEP_START smismember + // Recreate the set so this example runs on its own. + let _: usize = r.del("bikes:racing:france").unwrap_or(0); + let _: usize = r + .sadd("bikes:racing:france", &["bike:1", "bike:2", "bike:3"]) + .unwrap_or(0); + if let Ok(res) = r.sismember("bikes:racing:france", "bike:1") { let res: bool = res; println!("{}", i32::from(res)); // >>> 1 diff --git a/local_examples/tmp/datatypes/sets/SetsExample.java b/local_examples/tmp/datatypes/sets/SetsExample.java index a4802ee5ef..8d8ac859bb 100644 --- a/local_examples/tmp/datatypes/sets/SetsExample.java +++ b/local_examples/tmp/datatypes/sets/SetsExample.java @@ -106,6 +106,10 @@ public void run() { // REMOVE_END // STEP_START smismember + // Recreate the set so this example runs on its own. + jedis.del("bikes:racing:france"); + jedis.sadd("bikes:racing:france", "bike:1", "bike:2", "bike:3"); + boolean res11 = jedis.sismember("bikes:racing:france", "bike:1"); System.out.println(res11); // >>> true diff --git a/local_examples/tmp/datatypes/sets/SetsTutorial.cs b/local_examples/tmp/datatypes/sets/SetsTutorial.cs index e5a57a5ead..b220c8a74a 100644 --- a/local_examples/tmp/datatypes/sets/SetsTutorial.cs +++ b/local_examples/tmp/datatypes/sets/SetsTutorial.cs @@ -125,6 +125,10 @@ public void Run() // STEP_START smismember + // Recreate the set so this example runs on its own. + db.KeyDelete("bikes:racing:france"); + db.SetAdd("bikes:racing:france", ["bike:1", "bike:2", "bike:3"]); + bool res14 = db.SetContains("bikes:racing:france", "bike:1"); Console.WriteLine(res14); // >>> true diff --git a/local_examples/tmp/datatypes/sets/dt-set.js b/local_examples/tmp/datatypes/sets/dt-set.js index 959e2081fa..194854f49e 100644 --- a/local_examples/tmp/datatypes/sets/dt-set.js +++ b/local_examples/tmp/datatypes/sets/dt-set.js @@ -97,6 +97,10 @@ assert.deepEqual(res10.sort(), ['bike:1', 'bike:2', 'bike:3']) // REMOVE_END // STEP_START smIsMember +// Recreate the set so this example runs on its own. +await client.del('bikes:racing:france') +await client.sAdd('bikes:racing:france', ['bike:1', 'bike:2', 'bike:3']) + const res11 = await client.sIsMember('bikes:racing:france', 'bike:1') console.log(res11) // >>> 1 diff --git a/local_examples/tmp/datatypes/sets/dt_set.py b/local_examples/tmp/datatypes/sets/dt_set.py index 974261c3e3..89d5a0bd74 100644 --- a/local_examples/tmp/datatypes/sets/dt_set.py +++ b/local_examples/tmp/datatypes/sets/dt_set.py @@ -94,6 +94,10 @@ # REMOVE_END # STEP_START smismember +# Recreate the set so this example runs on its own. +r.delete("bikes:racing:france") +r.sadd("bikes:racing:france", "bike:1", "bike:2", "bike:3") + res11 = r.sismember("bikes:racing:france", "bike:1") print(res11) # >>> 1 diff --git a/local_examples/tmp/lettuce-async/SetExample.java b/local_examples/tmp/lettuce-async/SetExample.java new file mode 100644 index 0000000000..dfb361f664 --- /dev/null +++ b/local_examples/tmp/lettuce-async/SetExample.java @@ -0,0 +1,226 @@ +// EXAMPLE: sets_tutorial +package io.redis.examples.async; + +import io.lettuce.core.*; +import io.lettuce.core.api.async.RedisAsyncCommands; +import io.lettuce.core.api.StatefulRedisConnection; +// REMOVE_START +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +// REMOVE_END + +import java.util.*; +import java.util.concurrent.CompletableFuture; + +public class SetExample { + + // REMOVE_START + @Test + // REMOVE_END + public void run() { + RedisClient redisClient = RedisClient.create("redis://localhost:6379"); + + try (StatefulRedisConnection connection = redisClient.connect()) { + RedisAsyncCommands asyncCommands = connection.async(); + // REMOVE_START + // Clean up any existing data + asyncCommands.del("bikes:racing:france", "bikes:racing:usa", "bikes:racing:italy") + .toCompletableFuture().join(); + // REMOVE_END + + // STEP_START sadd + CompletableFuture sAdd = asyncCommands.sadd("bikes:racing:france", "bike:1").thenCompose(res1 -> { + System.out.println(res1); // >>> 1 + // REMOVE_START + assertThat(res1).isEqualTo(1L); + // REMOVE_END + return asyncCommands.sadd("bikes:racing:france", "bike:1"); + }).thenCompose(res2 -> { + System.out.println(res2); // >>> 0 + // REMOVE_START + assertThat(res2).isEqualTo(0L); + // REMOVE_END + return asyncCommands.sadd("bikes:racing:france", "bike:2", "bike:3"); + }).thenCompose(res3 -> { + System.out.println(res3); // >>> 2 + // REMOVE_START + assertThat(res3).isEqualTo(2L); + // REMOVE_END + return asyncCommands.sadd("bikes:racing:usa", "bike:1", "bike:4"); + }).thenAccept(res4 -> { + System.out.println(res4); // >>> 2 + // REMOVE_START + assertThat(res4).isEqualTo(2L); + // REMOVE_END + }).toCompletableFuture(); + // STEP_END + sAdd.join(); + + // STEP_START sismember + CompletableFuture sIsMember = asyncCommands.sismember("bikes:racing:usa", "bike:1").thenCompose(res5 -> { + System.out.println(res5); // >>> true + // REMOVE_START + assertThat(res5).isTrue(); + // REMOVE_END + return asyncCommands.sismember("bikes:racing:usa", "bike:2"); + }).thenAccept(res6 -> { + System.out.println(res6); // >>> false + // REMOVE_START + assertThat(res6).isFalse(); + // REMOVE_END + }).toCompletableFuture(); + // STEP_END + + // STEP_START sinter + CompletableFuture sInter = asyncCommands.sinter("bikes:racing:france", "bikes:racing:usa") + .thenAccept(res7 -> { + System.out.println(res7); // >>> [bike:1] + // REMOVE_START + assertThat(res7).containsExactly("bike:1"); + // REMOVE_END + }).toCompletableFuture(); + // STEP_END + + // STEP_START scard + CompletableFuture sCard = asyncCommands.scard("bikes:racing:france").thenAccept(res8 -> { + System.out.println(res8); // >>> 3 + // REMOVE_START + assertThat(res8).isEqualTo(3L); + // REMOVE_END + }).toCompletableFuture(); + // STEP_END + + CompletableFuture.allOf(sIsMember, sInter, sCard).join(); + + // STEP_START sadd_smembers + CompletableFuture sAddSMembers = asyncCommands.sadd("bikes:racing:france", "bike:1", "bike:2", "bike:3") + .thenCompose(res9 -> { + System.out.println(res9); // >>> 3 + // REMOVE_START + assertThat(res9).isEqualTo(0L); + // REMOVE_END + return asyncCommands.smembers("bikes:racing:france"); + }).thenAccept(res10 -> { + System.out.println(res10); // >>> [bike:1, bike:2, bike:3] + // REMOVE_START + assertThat(res10).containsExactlyInAnyOrder("bike:1", "bike:2", "bike:3"); + // REMOVE_END + }).toCompletableFuture(); + // STEP_END + sAddSMembers.join(); + + // STEP_START smismember + // Recreate the set so this example runs on its own. The chain is + // ordered, so the DEL + SADD complete before the reads below. + CompletableFuture sMIsMember = asyncCommands.del("bikes:racing:france") + .thenCompose(d -> asyncCommands.sadd("bikes:racing:france", "bike:1", "bike:2", "bike:3")) + .thenCompose(a -> asyncCommands.sismember("bikes:racing:france", "bike:1")).thenCompose(res11 -> { + System.out.println(res11); // >>> true + // REMOVE_START + assertThat(res11).isTrue(); + // REMOVE_END + return asyncCommands.smismember("bikes:racing:france", "bike:2", "bike:3", "bike:4"); + }).thenAccept(res12 -> { + System.out.println(res12); // >>> [true, true, false] + // REMOVE_START + assertThat(res12).containsExactly(true, true, false); + // REMOVE_END + }).toCompletableFuture(); + // STEP_END + sMIsMember.join(); + + // STEP_START sdiff + CompletableFuture sDiff = asyncCommands.sdiff("bikes:racing:france", "bikes:racing:usa") + .thenAccept(res13 -> { + System.out.println(res13); // >>> [bike:2, bike:3] + // REMOVE_START + assertThat(res13).containsExactlyInAnyOrder("bike:2", "bike:3"); + // REMOVE_END + }).toCompletableFuture(); + // STEP_END + sDiff.join(); + + // STEP_START multisets + CompletableFuture multisets = asyncCommands.sadd("bikes:racing:france", "bike:1", "bike:2", "bike:3") + .thenCompose(res14 -> { + System.out.println(res14); // >>> 0 + // REMOVE_START + assertThat(res14).isEqualTo(0L); + // REMOVE_END + return asyncCommands.sadd("bikes:racing:usa", "bike:1", "bike:4"); + }).thenCompose(res15 -> { + System.out.println(res15); // >>> 0 + // REMOVE_START + assertThat(res15).isEqualTo(0L); + // REMOVE_END + return asyncCommands.sadd("bikes:racing:italy", "bike:1", "bike:2", "bike:3", "bike:4"); + }).thenCompose(res16 -> { + System.out.println(res16); // >>> 4 + // REMOVE_START + assertThat(res16).isEqualTo(4L); + // REMOVE_END + return asyncCommands.sinter("bikes:racing:france", "bikes:racing:usa", "bikes:racing:italy"); + }).thenCompose(res17 -> { + System.out.println(res17); // >>> [bike:1] + // REMOVE_START + assertThat(res17).containsExactly("bike:1"); + // REMOVE_END + return asyncCommands.sunion("bikes:racing:france", "bikes:racing:usa", "bikes:racing:italy"); + }).thenCompose(res18 -> { + System.out.println(res18); // >>> [bike:1, bike:2, bike:3, bike:4] + // REMOVE_START + assertThat(res18).containsExactlyInAnyOrder("bike:1", "bike:2", "bike:3", "bike:4"); + // REMOVE_END + return asyncCommands.sdiff("bikes:racing:france", "bikes:racing:usa", "bikes:racing:italy"); + }).thenCompose(res19 -> { + System.out.println(res19); // >>> [] + // REMOVE_START + assertThat(res19).isEmpty(); + // REMOVE_END + return asyncCommands.sdiff("bikes:racing:usa", "bikes:racing:france"); + }).thenCompose(res20 -> { + System.out.println(res20); // >>> [bike:4] + // REMOVE_START + assertThat(res20).containsExactly("bike:4"); + // REMOVE_END + return asyncCommands.sdiff("bikes:racing:france", "bikes:racing:usa"); + }).thenAccept(res21 -> { + System.out.println(res21); // >>> [bike:2, bike:3] + // REMOVE_START + assertThat(res21).containsExactlyInAnyOrder("bike:2", "bike:3"); + // REMOVE_END + }).toCompletableFuture(); + // STEP_END + multisets.join(); + + // STEP_START srem + CompletableFuture sRem = asyncCommands + .sadd("bikes:racing:france", "bike:1", "bike:2", "bike:3", "bike:4", "bike:5").thenCompose(res22 -> { + System.out.println(res22); // >>> 2 + // REMOVE_START + assertThat(res22).isEqualTo(2L); + // REMOVE_END + return asyncCommands.srem("bikes:racing:france", "bike:1"); + }).thenCompose(res23 -> { + System.out.println(res23); // >>> 1 + // REMOVE_START + assertThat(res23).isEqualTo(1L); + // REMOVE_END + return asyncCommands.spop("bikes:racing:france"); + }).thenCompose(res24 -> { + System.out.println(res24); // >>> bike:3 (for example) + return asyncCommands.smembers("bikes:racing:france"); + }).thenCompose(res25 -> { + System.out.println(res25); // >>> [bike:2, bike:4, bike:5] (for example) + return asyncCommands.srandmember("bikes:racing:france"); + }).thenAccept(res26 -> { + System.out.println(res26); // >>> bike:4 (for example) + }).toCompletableFuture(); + // STEP_END + sRem.join(); + } finally { + redisClient.shutdown(); + } + } + +} diff --git a/local_examples/tmp/lettuce-reactive/SetExample.java b/local_examples/tmp/lettuce-reactive/SetExample.java index 30808e2d0d..87312431d6 100644 --- a/local_examples/tmp/lettuce-reactive/SetExample.java +++ b/local_examples/tmp/lettuce-reactive/SetExample.java @@ -108,6 +108,13 @@ public void run() { sAddSMembers.block(); // STEP_START smismember + // Recreate the set so this example runs on its own. Block it + // sequentially: a DEL inside the shared Mono.when() below would + // race the sibling reads on the same key. + reactiveCommands.del("bikes:racing:france") + .then(reactiveCommands.sadd("bikes:racing:france", "bike:1", "bike:2", "bike:3")) + .block(); + Mono sIsMember3 = reactiveCommands.sismember("bikes:racing:france", "bike:1").doOnNext(result -> { System.out.println(result); // >>> true // REMOVE_START From 4f3925e20a2ef5294bd37a86d0dfc2289d503917 Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Wed, 8 Jul 2026 16:40:19 +0100 Subject: [PATCH 10/28] DOC-6823 make geosearch runnable in the interactive CLI via needs_prereq MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the try_it="false" opt-out from the geosearch example. Rather than the zrank-style in-snippet recreate, geosearch is wired with the built-in prereq mechanism: geoadd is marked prereq="true" and geosearch needs_prereq="true", so the "Try it" button replays the three-station GEOADD before running GEOSEARCH. This is the correct, lighter tool here because geosearch depends only on the base add, not on any mid-sequence mutation (the reason zrank/smismember needed an explicit recreate). It also matters because geo_tutorial has no local overrides at all — every client tab renders straight from the upstream client repos — so a recreate would have meant creating five new override files; needs_prereq needs none. Validated against redis 8: fresh DB, replay geoadd, then the documented GEOSEARCH WITHDIST output (1.8523 / 1.4979 / 2.2441) matches. Learned: needs_prereq is the right fix when a step depends only on the set's base add; reserve the in-snippet recreate for steps that depend on a mid-sequence mutation (delete/trim) which needs_prereq cannot reproduce Rejected: zrank-style in-snippet recreate for geosearch | would have forced five new local client overrides since geo_tutorial is upstream-only, where needs_prereq requires none Gaps: verified at the redis-cli level and against the proven sets sadd/sinter shortcode pattern; did not build the Hugo site to click the rendered Try-it button Ticket: DOC-6823 Co-Authored-By: Claude Opus 4.8 (1M context) --- content/develop/data-types/geospatial.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content/develop/data-types/geospatial.md b/content/develop/data-types/geospatial.md index 9cca4ba060..2463aa6642 100644 --- a/content/develop/data-types/geospatial.md +++ b/content/develop/data-types/geospatial.md @@ -38,7 +38,7 @@ available in Redis Search. Suppose you're building a mobile app that lets you find all of the bike rental stations closest to your current location. Add several locations to a geospatial index: -{{< clients-example set="geo_tutorial" step="geoadd" description="Foundational: Add geographic coordinates to a geospatial index using GEOADD to store location data" >}} +{{< clients-example set="geo_tutorial" step="geoadd" description="Foundational: Add geographic coordinates to a geospatial index using GEOADD to store location data" prereq="true" >}} > GEOADD bikes:rentable -122.27652 37.805186 station:1 (integer) 1 > GEOADD bikes:rentable -122.2674626 37.8062344 station:2 @@ -48,7 +48,7 @@ Add several locations to a geospatial index: {{< /clients-example >}} Find all locations within a 5 kilometer radius of a given location, and return the distance to each location: -{{< clients-example set="geo_tutorial" step="geosearch" description="Proximity search: Use GEOSEARCH to find locations within a radius or bounding box when you need to discover nearby points" difficulty="intermediate" buildsUpon="geoadd" try_it="false" >}} +{{< clients-example set="geo_tutorial" step="geosearch" description="Proximity search: Use GEOSEARCH to find locations within a radius or bounding box when you need to discover nearby points" difficulty="intermediate" buildsUpon="geoadd" needs_prereq="true" >}} > GEOSEARCH bikes:rentable FROMLONLAT -122.2612767 37.7936847 BYRADIUS 5 km WITHDIST 1) 1) "station:1" 2) "1.8523" From 6735cf9ccccb249bac10ab96420322887bd50093 Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Thu, 9 Jul 2026 09:51:34 +0100 Subject: [PATCH 11/28] DOC-6823 run reactive StringExample steps sequentially, not under Mono.when MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reactive string tutorial composed all five steps with Mono.when(setAndGet, setnx, setxx, mset, incrby).block(), which subscribes the cold publishers concurrently. Four of them mutate bike:1 (SET/SETNX/SET XX/MSET), so setnx's "value is unchanged" read and the setxx/mset writes raced each other and could fail assertions intermittently under the harness. Replaced the concurrent when() with a sequential setAndGet.then(setnx).then(setxx).then(mset) .then(incrby) chain — deterministic, and matching how the async override sequences with per-step join() and how the sets reactive override blocks per step. Bugbot 3544441638 flagged this; its other two findings on this PR (async allOf "race", node harness "missing dep") were false positives — async del fires on-call and is dispatch-ordered on one connection, and the redis npm metapackage hoists @redis/time-series transitively. Learned: only read-only steps are safe to combine under a shared reactive Mono.when; steps that mutate the same key must be sequenced or they race each other's reads Constraint: in the Lettuce reactive doc overrides, never combine key-mutating steps under one Mono.when() — sequence them (.then chain or per-step .block()) Ticket: DOC-6823 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client-specific/lettuce-reactive/StringExample.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/local_examples/client-specific/lettuce-reactive/StringExample.java b/local_examples/client-specific/lettuce-reactive/StringExample.java index 0401a61898..ea7f1f164c 100644 --- a/local_examples/client-specific/lettuce-reactive/StringExample.java +++ b/local_examples/client-specific/lettuce-reactive/StringExample.java @@ -97,7 +97,9 @@ public void run() { }).then(); // STEP_END - Mono.when(setAndGet, setnx, setxx, mset, incrby).block(); + // Run the steps sequentially: several of them mutate bike:1, so a + // shared Mono.when() would race them against each other's reads. + setAndGet.then(setnx).then(setxx).then(mset).then(incrby).block(); } finally { redisClient.shutdown(); From 4c2284ffb8f3ce19e69e246c897b53ad196bd274 Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Thu, 9 Jul 2026 10:21:59 +0100 Subject: [PATCH 12/28] DOC-6823 use Double.NEGATIVE_INFINITY, not Double.MIN_VALUE, for -inf score bounds The Lettuce async/reactive and Jedis sorted-set examples passed Double.MIN_VALUE as the lower bound of ZRANGEBYSCORE/ZREMRANGEBYSCORE to mean -inf. But Double.MIN_VALUE is Java's smallest *positive* value (~4.9e-324), so the range was really ~0..N, not -inf..N. It happened to print the documented output here because every score is >= 6, but it silently drops zero/negative scores and teaches a pattern readers copy. Both Jedis and Lettuce serialize Double.NEGATIVE_INFINITY to the "-inf" the CLI example uses, so that is the correct constant. Bugbot 3550287458 flagged the two Lettuce files (new on this branch); the identical Jedis instance predates the branch (DOC-6057, 2569d4bc2) and is folded in here so all three sorted-set client tabs agree. Harness PASS for jedis, lettuce-async, and lettuce-reactive. Learned: Double.MIN_VALUE is the smallest positive double, not -inf; an unbounded lower score bound must be Double.NEGATIVE_INFINITY, which both Jedis and Lettuce emit as "-inf" Constraint: score-range lower bounds that mean -inf use Double.NEGATIVE_INFINITY, never Double.MIN_VALUE Directive: don't reintroduce Double.MIN_VALUE-as-(-inf) here; it was copied from the pre-existing jedis example, so check all three sorted-set client files together Ticket: DOC-6823 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client-specific/lettuce-async/SortedSetExample.java | 4 ++-- .../client-specific/lettuce-reactive/SortedSetExample.java | 4 ++-- .../tmp/datatypes/sorted-sets/SortedSetsExample.java | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/local_examples/client-specific/lettuce-async/SortedSetExample.java b/local_examples/client-specific/lettuce-async/SortedSetExample.java index 4a0fc8ca96..15ea3cd50c 100644 --- a/local_examples/client-specific/lettuce-async/SortedSetExample.java +++ b/local_examples/client-specific/lettuce-async/SortedSetExample.java @@ -96,7 +96,7 @@ public void run() { // STEP_START zrangebyscore CompletableFuture zrangebyscore = asyncCommands - .zrangebyscore("racer_scores", Range.create(Double.MIN_VALUE, 10)) + .zrangebyscore("racer_scores", Range.create(Double.NEGATIVE_INFINITY, 10)) // REMOVE_START .thenApply(res -> { assertThat(res.toString()).isEqualTo("[Ford, Sam-Bodden, Norem, Royce]"); @@ -116,7 +116,7 @@ public void run() { assertThat(res4).isEqualTo(1); // REMOVE_END - return asyncCommands.zremrangebyscore("racer_scores", Range.create(Double.MIN_VALUE, 9)); + return asyncCommands.zremrangebyscore("racer_scores", Range.create(Double.NEGATIVE_INFINITY, 9)); }).thenCompose(res5 -> { System.out.println(res5); // >>> 2 // REMOVE_START diff --git a/local_examples/client-specific/lettuce-reactive/SortedSetExample.java b/local_examples/client-specific/lettuce-reactive/SortedSetExample.java index cc74de8389..8439afb2fc 100644 --- a/local_examples/client-specific/lettuce-reactive/SortedSetExample.java +++ b/local_examples/client-specific/lettuce-reactive/SortedSetExample.java @@ -83,7 +83,7 @@ public void run() { // STEP_START zrangebyscore Mono zrangebyscore = reactiveCommands - .zrangebyscore("racer_scores", Range.create(Double.MIN_VALUE, 10)).collectList().doOnNext(result -> { + .zrangebyscore("racer_scores", Range.create(Double.NEGATIVE_INFINITY, 10)).collectList().doOnNext(result -> { System.out.println(result); // >>> [Ford, Sam-Bodden, Norem, Royce] // REMOVE_START assertThat(result.toString()).isEqualTo("[Ford, Sam-Bodden, Norem, Royce]"); @@ -98,7 +98,7 @@ public void run() { // REMOVE_START assertThat(result).isEqualTo(1); // REMOVE_END - }).flatMap(v -> reactiveCommands.zremrangebyscore("racer_scores", Range.create(Double.MIN_VALUE, 9))) + }).flatMap(v -> reactiveCommands.zremrangebyscore("racer_scores", Range.create(Double.NEGATIVE_INFINITY, 9))) .doOnNext(result -> { System.out.println(result); // >>> 2 // REMOVE_START diff --git a/local_examples/tmp/datatypes/sorted-sets/SortedSetsExample.java b/local_examples/tmp/datatypes/sorted-sets/SortedSetsExample.java index ded430e6e8..28792bf6f9 100644 --- a/local_examples/tmp/datatypes/sorted-sets/SortedSetsExample.java +++ b/local_examples/tmp/datatypes/sorted-sets/SortedSetsExample.java @@ -58,7 +58,7 @@ public void run() { //STEP_END //STEP_START zrangebyscore - List res7 = jedis.zrangeByScore("racer_scores", Double.MIN_VALUE, 10d); + List res7 = jedis.zrangeByScore("racer_scores", Double.NEGATIVE_INFINITY, 10d); System.out.println(res7); // >>> [Ford, Sam-Bodden, Norem, Royce] //STEP_END @@ -66,7 +66,7 @@ public void run() { long res8 = jedis.zrem("racer_scores", "Castilla"); System.out.println(res8); // >>> 1 - long res9 = jedis.zremrangeByScore("racer_scores", Double.MIN_VALUE, 9d); + long res9 = jedis.zremrangeByScore("racer_scores", Double.NEGATIVE_INFINITY, 9d); System.out.println(res9); // >>> 2 List res10 = jedis.zrange("racer_scores", 0, -1); From 17b158de98b67ce8e7d62444832068087ffdd543 Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Thu, 9 Jul 2026 12:54:45 +0100 Subject: [PATCH 13/28] DOC-6823 make document-database term queries runnable via a merged create+load prereq Enables the interactive CLI on the document-database quickstart's two term queries (query_single_term, query_exact_matching) by merging the create_index and add_documents steps into one prereq block (FT.CREATE + the ten JSON.SETs) and marking the queries needs_prereq. search_quickstart had no local client overrides (all upstream) and the two steps are separate in every client, so the merge is delivered as five local overrides that delete only the boundary STEP markers between create_index and add_documents -- behaviour-identical to upstream (only comment lines moved). The wildcard query is deliberately left opted out: FT.SEARCH "*" has no SORTBY, so its order is version-dependent (on Redis 8.8 it is bicycle:0,1,2,5,4,9,6,3,8,7 and the documented output is already stale), and enabling its Try-it button would promise a reproducible ordering we cannot guarantee. Verified on Redis 8.8: no async index-population race, and both term-query prereq replays return the exact documented documents. Learned: on Redis 8.8, JSON indexing kept up with back-to-back FT.CREATE + 10x JSON.SET + FT.SEARCH (no async-population race), so needs_prereq alone seeds the term queries reliably -- no settle/retry needed Constraint: the merged create_index step must contain BOTH the FT.CREATE and the document load; the two term queries depend on it through needs_prereq Rejected: enabling the wildcard query's Try-it button | FT.SEARCH "*" has no SORTBY, so its result order is RediSearch-version-dependent and unreproducible against the static documented output Directive: the five search_quickstart overrides are marker-only merges of the upstream client sources -- if you re-sync from upstream, re-apply the create_index/add_documents STEP merge rather than hand-editing the code Recheck: wildcard result order + the documented output are RediSearch-version-specific (checked on 8.8) Ticket: DOC-6823 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../develop/get-started/document-database.md | 20 +- .../go-redis/search_quickstart_test.go | 260 +++++++++++ .../jedis/SearchQuickstartExample.java | 280 ++++++++++++ .../node-redis/search-quickstart.js | 229 ++++++++++ .../nredisstack/SearchQuickstartExample.cs | 307 +++++++++++++ .../redis-py/search_quickstart.py | 423 ++++++++++++++++++ 6 files changed, 1505 insertions(+), 14 deletions(-) create mode 100644 local_examples/search_quickstart/go-redis/search_quickstart_test.go create mode 100644 local_examples/search_quickstart/jedis/SearchQuickstartExample.java create mode 100644 local_examples/search_quickstart/node-redis/search-quickstart.js create mode 100644 local_examples/search_quickstart/nredisstack/SearchQuickstartExample.cs create mode 100644 local_examples/search_quickstart/redis-py/search_quickstart.py diff --git a/content/develop/get-started/document-database.md b/content/develop/get-started/document-database.md index 4124f77bc7..92c66edb90 100644 --- a/content/develop/get-started/document-database.md +++ b/content/develop/get-started/document-database.md @@ -65,25 +65,17 @@ You can copy and paste the connection details from the Redis Cloud database conf {{% /alert %}} -## Create an index +## Create an index and add documents As explained in the [in-memory data store]({{< relref "/develop/get-started/data-store" >}}) quick start guide, Redis allows you to access an item directly via its key. You also learned how to scan the keyspace. Whereby you can use other data structures (e.g., hashes and sorted sets) as secondary indexes, your application would need to maintain those indexes manually. Redis is a document database that allows you to declare which fields are auto-indexed. Redis currently supports secondary index creation on the [hashes]({{< relref "/develop/data-types/hashes" >}}) and [JSON]({{< relref "/develop/data-types/json" >}}) documents. -The following example shows an [FT.CREATE]({{< relref "commands/ft.create" >}}) command that creates an index with some text fields, a numeric field (price), and a tag field (condition). The text fields have a weight of 1.0, meaning they have the same relevancy in the context of full-text searches. The field names follow the [JSONPath]({{< relref "/develop/data-types/json/path" >}}) notion. Each such index field maps to a property within the JSON document. +The following example uses an [FT.CREATE]({{< relref "commands/ft.create" >}}) command to create an index with some text fields, a numeric field (price), and a tag field (condition). The text fields have a weight of 1.0, meaning they have the same relevancy in the context of full-text searches. The field names follow the [JSONPath]({{< relref "/develop/data-types/json/path" >}}) notion. Each such index field maps to a property within the JSON document. The example then uses the [JSON.SET]({{< relref "commands/json.set" >}}) command to add the JSON documents that the index tracks. +Any pre-existing JSON documents with a key prefix `bicycle:` are automatically added to the index. Additionally, any JSON documents with that prefix created or modified after index creation are added or re-added to the index. -{{< clients-example set="search_quickstart" step="create_index" description="Foundational: Create an index on JSON documents using FT.CREATE with text, numeric, and tag fields" difficulty="beginner" >}} +{{< clients-example set="search_quickstart" step="create_index" description="Foundational: Create an index on JSON documents with FT.CREATE, then add the documents with JSON.SET" difficulty="beginner" max_lines="2" prereq="true" >}} > FT.CREATE idx:bicycle ON JSON PREFIX 1 bicycle: SCORE 1.0 SCHEMA $.brand AS brand TEXT WEIGHT 1.0 $.model AS model TEXT WEIGHT 1.0 $.description AS description TEXT WEIGHT 1.0 $.price AS price NUMERIC $.condition AS condition TAG SEPARATOR , OK -{{< /clients-example >}} - -Any pre-existing JSON documents with a key prefix `bicycle:` are automatically added to the index. Additionally, any JSON documents with that prefix created or modified after index creation are added or re-added to the index. - -## Add JSON documents - -The example below shows you how to use the [JSON.SET]({{< relref "commands/json.set" >}}) command to create new JSON documents: - -{{< clients-example set="search_quickstart" step="add_documents" description="Foundational: Add JSON documents to Redis using JSON.SET" difficulty="beginner" max_lines="2" try_it="false" >}} > JSON.SET "bicycle:0" "." "{\"brand\": \"Velorim\", \"model\": \"Jigger\", \"price\": 270, \"description\": \"Small and powerful, the Jigger is the best ride for the smallest of tikes! This is the tiniest kids\\u2019 pedal bike on the market available without a coaster brake, the Jigger is the vehicle of choice for the rare tenacious little rider raring to go.\", \"condition\": \"new\"}" OK > JSON.SET "bicycle:1" "." "{\"brand\": \"Bicyk\", \"model\": \"Hillcraft\", \"price\": 1200, \"description\": \"Kids want to ride with as little weight as possible. Especially on an incline! They may be at the age when a 27.5\\\" wheel bike is just too clumsy coming off a 24\\\" bike. The Hillcraft 26 is just the solution they need!\", \"condition\": \"used\"}" @@ -151,7 +143,7 @@ You can retrieve all indexed documents using the [FT.SEARCH]({{< relref "command The following command shows a simple single-term query for finding all bicycles with a specific model: -{{< clients-example set="search_quickstart" step="query_single_term" description="Foundational: Perform a single-term full-text query using FT.SEARCH to find documents matching a specific field value" difficulty="beginner" try_it="false" >}} +{{< clients-example set="search_quickstart" step="query_single_term" description="Foundational: Perform a single-term full-text query using FT.SEARCH to find documents matching a specific field value" difficulty="beginner" needs_prereq="true" >}} > FT.SEARCH "idx:bicycle" "@model:Jigger" LIMIT 0 10 1) (integer) 1 2) "bicycle:0" @@ -163,7 +155,7 @@ The following command shows a simple single-term query for finding all bicycles Below is a command to perform an exact match query that finds all bicycles with the brand name `Noka Bikes`. You must use double quotes around the search term when constructing an exact match query on a text field. -{{< clients-example set="search_quickstart" step="query_exact_matching" description="Foundational: Perform an exact match query using FT.SEARCH with double quotes to find documents with precise field values" difficulty="beginner" try_it="false" >}} +{{< clients-example set="search_quickstart" step="query_exact_matching" description="Foundational: Perform an exact match query using FT.SEARCH with double quotes to find documents with precise field values" difficulty="beginner" needs_prereq="true" >}} > FT.SEARCH "idx:bicycle" "@brand:\"Noka Bikes\"" LIMIT 0 10 1) (integer) 1 2) "bicycle:4" diff --git a/local_examples/search_quickstart/go-redis/search_quickstart_test.go b/local_examples/search_quickstart/go-redis/search_quickstart_test.go new file mode 100644 index 0000000000..a731f452a6 --- /dev/null +++ b/local_examples/search_quickstart/go-redis/search_quickstart_test.go @@ -0,0 +1,260 @@ +// EXAMPLE: search_quickstart +// HIDE_START +package example_commands_test + +import ( + "context" + "fmt" + + "github.com/redis/go-redis/v9" +) + +// HIDE_END + +var bicycles = []interface{}{ + map[string]interface{}{ + "brand": "Velorim", + "model": "Jigger", + "price": 270, + "description": "Small and powerful, the Jigger is the best ride " + + "for the smallest of tikes! This is the tiniest " + + "kids’ pedal bike on the market available without" + + " a coaster brake, the Jigger is the vehicle of " + + "choice for the rare tenacious little rider " + + "raring to go.", + "condition": "new", + }, + map[string]interface{}{ + "brand": "Bicyk", + "model": "Hillcraft", + "price": 1200, + "description": "Kids want to ride with as little weight as possible." + + " Especially on an incline! They may be at the age " + + "when a 27.5\" wheel bike is just too clumsy coming " + + "off a 24\" bike. The Hillcraft 26 is just the solution" + + " they need!", + "condition": "used", + }, + map[string]interface{}{ + "brand": "Nord", + "model": "Chook air 5", + "price": 815, + "description": "The Chook Air 5 gives kids aged six years and older " + + "a durable and uberlight mountain bike for their first" + + " experience on tracks and easy cruising through forests" + + " and fields. The lower top tube makes it easy to mount" + + " and dismount in any situation, giving your kids greater" + + " safety on the trails.", + "condition": "used", + }, + map[string]interface{}{ + "brand": "Eva", + "model": "Eva 291", + "price": 3400, + "description": "The sister company to Nord, Eva launched in 2005 as the" + + " first and only women-dedicated bicycle brand. Designed" + + " by women for women, allEva bikes are optimized for the" + + " feminine physique using analytics from a body metrics" + + " database. If you like 29ers, try the Eva 291. It’s a " + + "brand new bike for 2022.. This full-suspension, " + + "cross-country ride has been designed for velocity. The" + + " 291 has 100mm of front and rear travel, a superlight " + + "aluminum frame and fast-rolling 29-inch wheels. Yippee!", + "condition": "used", + }, + map[string]interface{}{ + "brand": "Noka Bikes", + "model": "Kahuna", + "price": 3200, + "description": "Whether you want to try your hand at XC racing or are " + + "looking for a lively trail bike that's just as inspiring" + + " on the climbs as it is over rougher ground, the Wilder" + + " is one heck of a bike built specifically for short women." + + " Both the frames and components have been tweaked to " + + "include a women’s saddle, different bars and unique " + + "colourway.", + "condition": "used", + }, + map[string]interface{}{ + "brand": "Breakout", + "model": "XBN 2.1 Alloy", + "price": 810, + "description": "The XBN 2.1 Alloy is our entry-level road bike – but that’s" + + " not to say that it’s a basic machine. With an internal " + + "weld aluminium frame, a full carbon fork, and the slick-shifting" + + " Claris gears from Shimano’s, this is a bike which doesn’t" + + " break the bank and delivers craved performance.", + "condition": "new", + }, + map[string]interface{}{ + "brand": "ScramBikes", + "model": "WattBike", + "price": 2300, + "description": "The WattBike is the best e-bike for people who still feel young" + + " at heart. It has a Bafang 1000W mid-drive system and a 48V" + + " 17.5AH Samsung Lithium-Ion battery, allowing you to ride for" + + " more than 60 miles on one charge. It’s great for tackling hilly" + + " terrain or if you just fancy a more leisurely ride. With three" + + " working modes, you can choose between E-bike, assisted bicycle," + + " and normal bike modes.", + "condition": "new", + }, + map[string]interface{}{ + "brand": "Peaknetic", + "model": "Secto", + "price": 430, + "description": "If you struggle with stiff fingers or a kinked neck or back after" + + " a few minutes on the road, this lightweight, aluminum bike" + + " alleviates those issues and allows you to enjoy the ride. From" + + " the ergonomic grips to the lumbar-supporting seat position, the" + + " Roll Low-Entry offers incredible comfort. The rear-inclined seat" + + " tube facilitates stability by allowing you to put a foot on the" + + " ground to balance at a stop, and the low step-over frame makes it" + + " accessible for all ability and mobility levels. The saddle is" + + " very soft, with a wide back to support your hip joints and a" + + " cutout in the center to redistribute that pressure. Rim brakes" + + " deliver satisfactory braking control, and the wide tires provide" + + " a smooth, stable ride on paved roads and gravel. Rack and fender" + + " mounts facilitate setting up the Roll Low-Entry as your preferred" + + " commuter, and the BMX-like handlebar offers space for mounting a" + + " flashlight, bell, or phone holder.", + "condition": "new", + }, + map[string]interface{}{ + "brand": "nHill", + "model": "Summit", + "price": 1200, + "description": "This budget mountain bike from nHill performs well both on bike" + + " paths and on the trail. The fork with 100mm of travel absorbs" + + " rough terrain. Fat Kenda Booster tires give you grip in corners" + + " and on wet trails. The Shimano Tourney drivetrain offered enough" + + " gears for finding a comfortable pace to ride uphill, and the" + + " Tektro hydraulic disc brakes break smoothly. Whether you want an" + + " affordable bike that you can take to work, but also take trail in" + + " mountains on the weekends or you’re just after a stable," + + " comfortable ride for the bike path, the Summit gives a good value" + + " for money.", + "condition": "new", + }, + map[string]interface{}{ + "model": "ThrillCycle", + "brand": "BikeShind", + "price": 815, + "description": "An artsy, retro-inspired bicycle that’s as functional as it is" + + " pretty: The ThrillCycle steel frame offers a smooth ride. A" + + " 9-speed drivetrain has enough gears for coasting in the city, but" + + " we wouldn’t suggest taking it to the mountains. Fenders protect" + + " you from mud, and a rear basket lets you transport groceries," + + " flowers and books. The ThrillCycle comes with a limited lifetime" + + " warranty, so this little guy will last you long past graduation.", + "condition": "refurbished", + }, +} + +func ExampleClient_search_qs() { + // STEP_START connect + ctx := context.Background() + + rdb := redis.NewClient(&redis.Options{ + Addr: "localhost:6379", + Password: "", // no password docs + DB: 0, // use default DB + Protocol: 2, + }) + // STEP_END + + // REMOVE_START + rdb.FTDropIndex(ctx, "idx:bicycle") + // REMOVE_END + + // STEP_START create_index + schema := []*redis.FieldSchema{ + { + FieldName: "$.brand", + As: "brand", + FieldType: redis.SearchFieldTypeText, + }, + { + FieldName: "$.model", + As: "model", + FieldType: redis.SearchFieldTypeText, + }, + { + FieldName: "$.description", + As: "description", + FieldType: redis.SearchFieldTypeText, + }, + } + + _, err := rdb.FTCreate(ctx, "idx:bicycle", + &redis.FTCreateOptions{ + Prefix: []interface{}{"bicycle:"}, + OnJSON: true, + }, + schema..., + ).Result() + + if err != nil { + panic(err) + } + + for i, bicycle := range bicycles { + _, err := rdb.JSONSet( + ctx, + fmt.Sprintf("bicycle:%v", i), + "$", + bicycle, + ).Result() + + if err != nil { + panic(err) + } + } + // STEP_END + + // STEP_START wildcard_query + wCardResult, err := rdb.FTSearch(ctx, "idx:bicycle", "*").Result() + + if err != nil { + panic(err) + } + + fmt.Printf("Documents found: %v\n", wCardResult.Total) + // >>> Documents found: 10 + // STEP_END + + // STEP_START query_single_term + stResult, err := rdb.FTSearch( + ctx, + "idx:bicycle", + "@model:Jigger", + ).Result() + + if err != nil { + panic(err) + } + + fmt.Println(stResult) + // >>> {1 [{bicycle:0 map[$:{"brand":"Velorim", ... + // STEP_END + + // STEP_START query_exact_matching + exactMatchResult, err := rdb.FTSearch( + ctx, + "idx:bicycle", + "@brand:\"Noka Bikes\"", + ).Result() + + if err != nil { + panic(err) + } + + fmt.Println(exactMatchResult) + // >>> {1 [{bicycle:4 map[$:{"brand":"Noka Bikes"... + // STEP_END + + // Output: + // Documents found: 10 + // {1 [{bicycle:0 map[$:{"brand":"Velorim","condition":"new","description":"Small and powerful, the Jigger is the best ride for the smallest of tikes! This is the tiniest kids’ pedal bike on the market available without a coaster brake, the Jigger is the vehicle of choice for the rare tenacious little rider raring to go.","model":"Jigger","price":270}] }] []} + // {1 [{bicycle:4 map[$:{"brand":"Noka Bikes","condition":"used","description":"Whether you want to try your hand at XC racing or are looking for a lively trail bike that's just as inspiring on the climbs as it is over rougher ground, the Wilder is one heck of a bike built specifically for short women. Both the frames and components have been tweaked to include a women’s saddle, different bars and unique colourway.","model":"Kahuna","price":3200}] }] []} +} diff --git a/local_examples/search_quickstart/jedis/SearchQuickstartExample.java b/local_examples/search_quickstart/jedis/SearchQuickstartExample.java new file mode 100644 index 0000000000..3bd8d8c12e --- /dev/null +++ b/local_examples/search_quickstart/jedis/SearchQuickstartExample.java @@ -0,0 +1,280 @@ +// EXAMPLE: search_quickstart +package io.redis.examples; + +import java.math.BigDecimal; +import java.util.*; + +import redis.clients.jedis.*; +import redis.clients.jedis.exceptions.*; +import redis.clients.jedis.search.*; +import redis.clients.jedis.search.aggr.*; +import redis.clients.jedis.search.schemafields.*; +// REMOVE_START +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +// REMOVE_END + +class Bicycle { + public String brand; + public String model; + public BigDecimal price; + public String description; + public String condition; + + public Bicycle(String brand, String model, BigDecimal price, String condition, String description) { + this.brand = brand; + this.model = model; + this.price = price; + this.condition = condition; + this.description = description; + } +} + +public class SearchQuickstartExample { + + @Test + public void run() { + // STEP_START connect + RedisClient jedis = RedisClient.create("localhost", 6379); + // STEP_END + // REMOVE_START + try { + jedis.ftDropIndex("idx:bicycle"); + } catch (JedisDataException e) { + System.out.println("Can't connect to Redis: " + e.getMessage()); + } + // REMOVE_END + + // STEP_START create_index + SchemaField[] schema = { + TextField.of("$.brand").as("brand"), + TextField.of("$.model").as("model"), + TextField.of("$.description").as("description"), + NumericField.of("$.price").as("price"), + TagField.of("$.condition").as("condition") + }; + + jedis.ftCreate("idx:bicycle", + FTCreateParams.createParams() + .on(IndexDataType.JSON) + .addPrefix("bicycle:"), + schema + ); + + Bicycle[] bicycles = { + new Bicycle( + "Velorim", + "Jigger", + new BigDecimal(270), + "new", + "Small and powerful, the Jigger is the best ride " + + "for the smallest of tikes! This is the tiniest " + + "kids’ pedal bike on the market available without" + + " a coaster brake, the Jigger is the vehicle of " + + "choice for the rare tenacious little rider " + + "raring to go." + ), + new Bicycle( + "Bicyk", + "Hillcraft", + new BigDecimal(1200), + "used", + "Kids want to ride with as little weight as possible." + + " Especially on an incline! They may be at the age " + + "when a 27.5 inch wheel bike is just too clumsy coming " + + "off a 24 inch bike. The Hillcraft 26 is just the solution" + + " they need!" + ), + new Bicycle( + "Nord", + "Chook air 5", + new BigDecimal(815), + "used", + "The Chook Air 5 gives kids aged six years and older " + + "a durable and uberlight mountain bike for their first" + + " experience on tracks and easy cruising through forests" + + " and fields. The lower top tube makes it easy to mount" + + " and dismount in any situation, giving your kids greater" + + " safety on the trails." + ), + new Bicycle( + "Eva", + "Eva 291", + new BigDecimal(3400), + "used", + "The sister company to Nord, Eva launched in 2005 as the" + + " first and only women-dedicated bicycle brand. Designed" + + " by women for women, allEva bikes are optimized for the" + + " feminine physique using analytics from a body metrics" + + " database. If you like 29ers, try the Eva 291. It's a " + + "brand new bike for 2022.. This full-suspension, " + + "cross-country ride has been designed for velocity. The" + + " 291 has 100mm of front and rear travel, a superlight " + + "aluminum frame and fast-rolling 29-inch wheels. Yippee!" + ), + new Bicycle( + "Noka Bikes", + "Kahuna", + new BigDecimal(3200), + "used", + "Whether you want to try your hand at XC racing or are " + + "looking for a lively trail bike that's just as inspiring" + + " on the climbs as it is over rougher ground, the Wilder" + + " is one heck of a bike built specifically for short women." + + " Both the frames and components have been tweaked to " + + "include a women’s saddle, different bars and unique " + + "colourway." + ), + new Bicycle( + "Breakout", + "XBN 2.1 Alloy", + new BigDecimal(810), + "new", + "The XBN 2.1 Alloy is our entry-level road bike – but that’s" + + " not to say that it’s a basic machine. With an internal " + + "weld aluminium frame, a full carbon fork, and the slick-shifting" + + " Claris gears from Shimano’s, this is a bike which doesn’t" + + " break the bank and delivers craved performance." + ), + new Bicycle( + "ScramBikes", + "WattBike", + new BigDecimal(2300), + "new", + "The WattBike is the best e-bike for people who still feel young" + + " at heart. It has a Bafang 1000W mid-drive system and a 48V" + + " 17.5AH Samsung Lithium-Ion battery, allowing you to ride for" + + " more than 60 miles on one charge. It’s great for tackling hilly" + + " terrain or if you just fancy a more leisurely ride. With three" + + " working modes, you can choose between E-bike, assisted bicycle," + + " and normal bike modes." + ), + new Bicycle( + "Peaknetic", + "Secto", + new BigDecimal(430), + "new", + "If you struggle with stiff fingers or a kinked neck or back after" + + " a few minutes on the road, this lightweight, aluminum bike" + + " alleviates those issues and allows you to enjoy the ride. From" + + " the ergonomic grips to the lumbar-supporting seat position, the" + + " Roll Low-Entry offers incredible comfort. The rear-inclined seat" + + " tube facilitates stability by allowing you to put a foot on the" + + " ground to balance at a stop, and the low step-over frame makes it" + + " accessible for all ability and mobility levels. The saddle is" + + " very soft, with a wide back to support your hip joints and a" + + " cutout in the center to redistribute that pressure. Rim brakes" + + " deliver satisfactory braking control, and the wide tires provide" + + " a smooth, stable ride on paved roads and gravel. Rack and fender" + + " mounts facilitate setting up the Roll Low-Entry as your preferred" + + " commuter, and the BMX-like handlebar offers space for mounting a" + + " flashlight, bell, or phone holder." + ), + new Bicycle( + "nHill", + "Summit", + new BigDecimal(1200), + "new", + "This budget mountain bike from nHill performs well both on bike" + + " paths and on the trail. The fork with 100mm of travel absorbs" + + " rough terrain. Fat Kenda Booster tires give you grip in corners" + + " and on wet trails. The Shimano Tourney drivetrain offered enough" + + " gears for finding a comfortable pace to ride uphill, and the" + + " Tektro hydraulic disc brakes break smoothly. Whether you want an" + + " affordable bike that you can take to work, but also take trail in" + + " mountains on the weekends or you’re just after a stable," + + " comfortable ride for the bike path, the Summit gives a good value" + + " for money." + ), + new Bicycle( + "ThrillCycle", + "BikeShind", + new BigDecimal(815), + "refurbished", + "An artsy, retro-inspired bicycle that’s as functional as it is" + + " pretty: The ThrillCycle steel frame offers a smooth ride. A" + + " 9-speed drivetrain has enough gears for coasting in the city, but" + + " we wouldn’t suggest taking it to the mountains. Fenders protect" + + " you from mud, and a rear basket lets you transport groceries," + + " flowers and books. The ThrillCycle comes with a limited lifetime" + + " warranty, so this little guy will last you long past graduation." + ), + }; + + for (int i = 0; i < bicycles.length; i++) { + jedis.jsonSetWithEscape(String.format("bicycle:%d", i), bicycles[i]); + } + // STEP_END + + // STEP_START wildcard_query + Query query1 = new Query("*"); + List result1 = jedis.ftSearch("idx:bicycle", query1).getDocuments(); + System.out.println("Documents found:" + result1.size()); + // Prints: Documents found: 10 + // STEP_END + // REMOVE_START + assertEquals(10, result1.size(), "Validate total results"); + // REMOVE_END + + // STEP_START query_single_term + Query query2 = new Query("@model:Jigger"); + List result2 = jedis.ftSearch("idx:bicycle", query2).getDocuments(); + System.out.println(result2); + // Prints: [id:bicycle:0, score: 1.0, payload:null, + // properties:[$={"brand":"Velorim","model":"Jigger","price":270,"description":"Small and powerful, the Jigger is the best ride for the smallest of tikes! This is the tiniest kids’ pedal bike on the market available without a coaster brake, the Jigger is the vehicle of choice for the rare tenacious little rider raring to go.","condition":"new"}]] + // STEP_END + // REMOVE_START + assertEquals("bicycle:0", result2.get(0).getId(), "Validate bike id"); + // REMOVE_END + + // STEP_START query_single_term_limit_fields + Query query3 = new Query("@model:Jigger").returnFields("price"); + List result3 = jedis.ftSearch("idx:bicycle", query3).getDocuments(); + System.out.println(result3); + // Prints: [id:bicycle:0, score: 1.0, payload:null, properties:[price=270]] + // STEP_END + // REMOVE_START + assertEquals("bicycle:0", result3.get(0).getId(),"Validate cargo bike id"); + // REMOVE_END + + // STEP_START query_single_term_and_num_range + Query query4 = new Query("basic @price:[500 1000]"); + List result4 = jedis.ftSearch("idx:bicycle", query4).getDocuments(); + System.out.println(result4); + // Prints: [id:bicycle:5, score: 1.0, payload:null, + // properties:[$={"brand":"Breakout","model":"XBN 2.1 Alloy","price":810,"description":"The XBN 2.1 Alloy is our entry-level road bike – but that’s not to say that it’s a basic machine. With an internal weld aluminium frame, a full carbon fork, and the slick-shifting Claris gears from Shimano’s, this is a bike which doesn’t break the bank and delivers craved performance.","condition":"new"}]] + // STEP_END + // REMOVE_START + assertEquals("bicycle:5", result4.get(0).getId(), "Validate bike id"); + // REMOVE_END + + // STEP_START query_exact_matching + Query query5 = new Query("@brand:\"Noka Bikes\""); + List result5 = jedis.ftSearch("idx:bicycle", query5).getDocuments(); + System.out.println(result5); + // Prints: [id:bicycle:4, score: 1.0, payload:null, + // properties:[$={"brand":"Noka Bikes","model":"Kahuna","price":3200,"description":"Whether you want to try your hand at XC racing or are looking for a lively trail bike that's just as inspiring on the climbs as it is over rougher ground, the Wilder is one heck of a bike built specifically for short women. Both the frames and components have been tweaked to include a women’s saddle, different bars and unique colourway.","condition":"used"}]] + // STEP_END + // REMOVE_START + assertEquals("bicycle:4", result5.get(0).getId(), "Validate bike id"); + // REMOVE_END + + // STEP_START simple_aggregation + AggregationBuilder ab = new AggregationBuilder("*").groupBy("@condition", + Reducers.count().as("count")); + AggregationResult ar = jedis.ftAggregate("idx:bicycle", ab); + for (int i = 0; i < ar.getTotalResults(); i++) { + System.out.println(ar.getRow(i).getString("condition") + " - " + + ar.getRow(i).getString("count")); + } + // Prints: + // refurbished - 1 + // used - 5 + // new - 4 + assertEquals(3, ar.getTotalResults(), "Validate aggregation results"); + // STEP_END + + jedis.close(); + } +} \ No newline at end of file diff --git a/local_examples/search_quickstart/node-redis/search-quickstart.js b/local_examples/search_quickstart/node-redis/search-quickstart.js new file mode 100644 index 0000000000..44d48f39fa --- /dev/null +++ b/local_examples/search_quickstart/node-redis/search-quickstart.js @@ -0,0 +1,229 @@ +// EXAMPLE: search_quickstart +// REMOVE_START +import assert from 'assert'; +// REMOVE_END +// HIDE_START +import { createClient, SCHEMA_FIELD_TYPE } from 'redis'; +// HIDE_END +// STEP_START connect +const client = createClient(); +client.on('error', err => console.log('Redis Client Error', err)); + +await client.connect(); +// STEP_END + +// STEP_START data_sample +const bicycle1 = { + brand: 'Velorim', + model: 'Jigger', + price: 270, + description: + 'Small and powerful, the Jigger is the best ' + + 'ride for the smallest of tikes! This is the tiniest kids\u2019 ' + + 'pedal bike on the market available without a coaster brake, the ' + + 'Jigger is the vehicle of choice for the rare tenacious little' + + 'rider raring to go.', + condition: 'new' +}; +// STEP_END +const bicycles = [ + bicycle1, + { + brand: 'Bicyk', + model: 'Hillcraft', + price: 1200, + description: 'Kids want to ride with as little weight as possible. Especially on an incline! They may be at the age when a 27.5\" wheel bike is just too clumsy coming off a 24\" bike. The Hillcraft 26 is just the solution they need!', + condition: 'used' + }, + { + brand: 'Nord', + model: 'Chook air 5', + price: 815, + description: 'The Chook Air 5 gives kids aged six years and older a durable and uberlight mountain bike for their first experience on tracks and easy cruising through forests and fields. The lower top tube makes it easy to mount and dismount in any situation, giving your kids greater safety on the trails.', + condition: 'used' + }, + { + brand: 'Eva', + model: 'Eva 291', + price: 3400, + description: 'The sister company to Nord, Eva launched in 2005 as the first and only women-dedicated bicycle brand. Designed by women for women, allEva bikes are optimized for the feminine physique using analytics from a body metrics database. If you like 29ers, try the Eva 291. It\u2019s a brand new bike for 2022.. This full-suspension, cross-country ride has been designed for velocity. The 291 has 100mm of front and rear travel, a superlight aluminum frame and fast-rolling 29-inch wheels. Yippee!', + condition: 'used' + }, + { + brand: 'Noka Bikes', + model: 'Kahuna', + price: 3200, + description: 'Whether you want to try your hand at XC racing or are looking for a lively trail bike that\'s just as inspiring on the climbs as it is over rougher ground, the Wilder is one heck of a bike built specifically for short women. Both the frames and components have been tweaked to include a women\u2019s saddle, different bars and unique colourway.', + condition: 'used' + }, + { + brand: 'Breakout', + model: 'XBN 2.1 Alloy', + price: 810, + description: 'The XBN 2.1 Alloy is our entry-level road bike \u2013 but that\u2019s not to say that it\u2019s a basic machine. With an internal weld aluminium frame, a full carbon fork, and the slick-shifting Claris gears from Shimano\u2019s, this is a bike which doesn\u2019t break the bank and delivers craved performance.', + condition: 'new' + }, + { + brand: 'ScramBikes', + model: 'WattBike', + price: 2300, + description: 'The WattBike is the best e-bike for people who still feel young at heart. It has a Bafang 1000W mid-drive system and a 48V 17.5AH Samsung Lithium-Ion battery, allowing you to ride for more than 60 miles on one charge. It\u2019s great for tackling hilly terrain or if you just fancy a more leisurely ride. With three working modes, you can choose between E-bike, assisted bicycle, and normal bike modes.', + condition: 'new' + }, + { + brand: 'Peaknetic', + model: 'Secto', + price: 430, + description: 'If you struggle with stiff fingers or a kinked neck or back after a few minutes on the road, this lightweight, aluminum bike alleviates those issues and allows you to enjoy the ride. From the ergonomic grips to the lumbar-supporting seat position, the Roll Low-Entry offers incredible comfort. The rear-inclined seat tube facilitates stability by allowing you to put a foot on the ground to balance at a stop, and the low step-over frame makes it accessible for all ability and mobility levels. The saddle is very soft, with a wide back to support your hip joints and a cutout in the center to redistribute that pressure. Rim brakes deliver satisfactory braking control, and the wide tires provide a smooth, stable ride on paved roads and gravel. Rack and fender mounts facilitate setting up the Roll Low-Entry as your preferred commuter, and the BMX-like handlebar offers space for mounting a flashlight, bell, or phone holder.', + condition: 'new' + }, + { + brand: 'nHill', + model: 'Summit', + price: 1200, + description: 'This budget mountain bike from nHill performs well both on bike paths and on the trail. The fork with 100mm of travel absorbs rough terrain. Fat Kenda Booster tires give you grip in corners and on wet trails. The Shimano Tourney drivetrain offered enough gears for finding a comfortable pace to ride uphill, and the Tektro hydraulic disc brakes break smoothly. Whether you want an affordable bike that you can take to work, but also take trail in mountains on the weekends or you\u2019re just after a stable, comfortable ride for the bike path, the Summit gives a good value for money.', + condition: 'new' + }, + { + model: 'ThrillCycle', + brand: 'BikeShind', + price: 815, + description: 'An artsy, retro-inspired bicycle that\u2019s as functional as it is pretty: The ThrillCycle steel frame offers a smooth ride. A 9-speed drivetrain has enough gears for coasting in the city, but we wouldn\u2019t suggest taking it to the mountains. Fenders protect you from mud, and a rear basket lets you transport groceries, flowers and books. The ThrillCycle comes with a limited lifetime warranty, so this little guy will last you long past graduation.', + condition: 'refurbished' + } +]; +// STEP_START create_index +const schema = { + '$.brand': { + type: SCHEMA_FIELD_TYPE.TEXT, + SORTABLE: true, + AS: 'brand' + }, + '$.model': { + type: SCHEMA_FIELD_TYPE.TEXT, + AS: 'model' + }, + '$.description': { + type: SCHEMA_FIELD_TYPE.TEXT, + AS: 'description' + }, + '$.price': { + type: SCHEMA_FIELD_TYPE.NUMERIC, + AS: 'price' + }, + '$.condition': { + type: SCHEMA_FIELD_TYPE.TAG, + AS: 'condition' + } +}; + +try { + await client.ft.create('idx:bicycle', schema, { + ON: 'JSON', + PREFIX: 'bicycle:' + }); +} catch (e) { + if (e.message === 'Index already exists') { + console.log('Index exists already, skipped creation.'); + } else { + // Something went wrong, perhaps RediSearch isn't installed... + console.error(e); + process.exit(1); + } +} + +await Promise.all( + bicycles.map((bicycle, i) => client.json.set(`bicycle:${i}`, '$', bicycle)) +); +// STEP_END + +// STEP_START wildcard_query +let result = await client.ft.search('idx:bicycle', '*', { + LIMIT: { + from: 0, + size: 10 + } +}); + +console.log(JSON.stringify(result, null, 2)); + +/* +{ + "total": 10, + "documents": ... +} +*/ +// STEP_END + +// REMOVE_START +assert.equal(result.documents[0].id, 'bicycle:0'); +// REMOVE_END + +// STEP_START query_single_term +result = await client.ft.search( + 'idx:bicycle', + '@model:Jigger', + { + LIMIT: { + from: 0, + size: 10 + } +}); + +console.log(JSON.stringify(result, null, 2)); +/* +{ + "total": 1, + "documents": [{ + "id": "bicycle:0", + "value": { + "brand": "Velorim", + "model": "Jigger", + "price": 270, + "description": "Small and powerful, the Jigger is the best ride for the smallest of tikes! This is the tiniest kids’ pedal bike on the market available without a coaster brake, the Jigger is the vehicle of choice for the rare tenacious little rider raring to go.", + "condition": "new" + } + }] +} + */ +// STEP_END +// REMOVE_START +assert.equal(result.documents[0].id, 'bicycle:0'); +// REMOVE_END + +// STEP_START query_exact_matching +result = await client.ft.search( + 'idx:bicycle', + '@brand:"Noka Bikes"', + { + LIMIT: { + from: 0, + size: 10 + } + } +); + +console.log(JSON.stringify(result, null, 2)); + +/* +{ + "total": 1, + "documents": [{ + "id": "bicycle:4", + "value": { + "brand": "Noka Bikes", + "model": "Kahuna", + "price": 3200, + "description": "Whether you want to try your hand at XC racing or are looking for a lively trail bike that's just as inspiring on the climbs as it is over rougher ground, the Wilder is one heck of a bike built specifically for short women. Both the frames and components have been tweaked to include a women’s saddle, different bars and unique colourway.", + "condition": "used" + } + }] +} +*/ +// STEP_END + +// REMOVE_START +assert.equal(result.documents[0].id, 'bicycle:4'); +// REMOVE END + +await client.close(); diff --git a/local_examples/search_quickstart/nredisstack/SearchQuickstartExample.cs b/local_examples/search_quickstart/nredisstack/SearchQuickstartExample.cs new file mode 100644 index 0000000000..dfbab288f1 --- /dev/null +++ b/local_examples/search_quickstart/nredisstack/SearchQuickstartExample.cs @@ -0,0 +1,307 @@ +// EXAMPLE: search_quickstart + +using NRedisStack.RedisStackCommands; +using NRedisStack.Search; +using NRedisStack.Search.Aggregation; +using NRedisStack.Search.Literals.Enums; +using NRedisStack.Tests; +using StackExchange.Redis; + +// REMOVE_START +namespace Doc; + +[Collection("DocsTests")] +// REMOVE_END +public class SearchQuickstartExample +// REMOVE_START +: AbstractNRedisStackTest, IDisposable +// REMOVE_END +{ + // REMOVE_START + public SearchQuickstartExample(EndpointsFixture fixture) : base(fixture) { } + + [Fact] + // REMOVE_END + public void Run() + { + //REMOVE_START + // This is needed because we're constructing ConfigurationOptions in the test before calling GetConnection + SkipIfTargetConnectionDoesNotExist(EndpointsFixture.Env.Standalone); + var _ = GetCleanDatabase(EndpointsFixture.Env.Standalone); + //REMOVE_END + // STEP_START connect + var muxer = ConnectionMultiplexer.Connect("localhost:6379"); + var db = muxer.GetDatabase(); + var ft = db.FT(); + var json = db.JSON(); + // STEP_END + + // REMOVE_START + try + { + ft.DropIndex("idx:bicycle"); + } + catch + { + // ignored + } + // REMOVE_END + + // STEP_START data_sample + var bike1 = new + { + Brand = "Velorim", + Model = "Jigger", + Price = 270M, + Description = "Small and powerful, the Jigger is the best ride " + + "for the smallest of tikes! This is the tiniest " + + "kids’ pedal bike on the market available without" + + " a coaster brake, the Jigger is the vehicle of " + + "choice for the rare tenacious little rider " + + "raring to go.", + Condition = "used" + }; + // STEP_END + + var bicycles = new object[] + { + bike1, + new + { + Brand = "Bicyk", + Model = "Hillcraft", + Price = 1200M, + Description = "Kids want to ride with as little weight as possible." + + " Especially on an incline! They may be at the age " + + "when a 27.5 inch wheel bike is just too clumsy coming " + + "off a 24 inch bike. The Hillcraft 26 is just the solution" + + " they need!", + Condition = "used", + }, + new + { + Brand = "Nord", + Model = "Chook air 5", + Price = 815M, + Description = "The Chook Air 5 gives kids aged six years and older " + + "a durable and uberlight mountain bike for their first" + + " experience on tracks and easy cruising through forests" + + " and fields. The lower top tube makes it easy to mount" + + " and dismount in any situation, giving your kids greater" + + " safety on the trails.", + Condition = "used", + }, + new + { + Brand = "Eva", + Model = "Eva 291", + Price = 3400M, + Description = "The sister company to Nord, Eva launched in 2005 as the" + + " first and only women-dedicated bicycle brand. Designed" + + " by women for women, allEva bikes are optimized for the" + + " feminine physique using analytics from a body metrics" + + " database. If you like 29ers, try the Eva 291. It’s a " + + "brand new bike for 2022.. This full-suspension, " + + "cross-country ride has been designed for velocity. The" + + " 291 has 100mm of front and rear travel, a superlight " + + "aluminum frame and fast-rolling 29-inch wheels. Yippee!", + Condition = "used", + }, + new + { + Brand = "Noka Bikes", + Model = "Kahuna", + Price = 3200M, + Description = "Whether you want to try your hand at XC racing or are " + + "looking for a lively trail bike that's just as inspiring" + + " on the climbs as it is over rougher ground, the Wilder" + + " is one heck of a bike built specifically for short women." + + " Both the frames and components have been tweaked to " + + "include a women’s saddle, different bars and unique " + + "colourway.", + Condition = "used", + }, + new + { + Brand = "Breakout", + Model = "XBN 2.1 Alloy", + Price = 810M, + Description = "The XBN 2.1 Alloy is our entry-level road bike – but that’s" + + " not to say that it’s a basic machine. With an internal " + + "weld aluminium frame, a full carbon fork, and the slick-shifting" + + " Claris gears from Shimano’s, this is a bike which doesn’t" + + " break the bank and delivers craved performance.", + Condition = "new", + }, + new + { + Brand = "ScramBikes", + Model = "WattBike", + Price = 2300M, + Description = "The WattBike is the best e-bike for people who still feel young" + + " at heart. It has a Bafang 1000W mid-drive system and a 48V" + + " 17.5AH Samsung Lithium-Ion battery, allowing you to ride for" + + " more than 60 miles on one charge. It’s great for tackling hilly" + + " terrain or if you just fancy a more leisurely ride. With three" + + " working modes, you can choose between E-bike, assisted bicycle," + + " and normal bike modes.", + Condition = "new", + }, + new + { + Brand = "Peaknetic", + Model = "Secto", + Price = 430M, + Description = "If you struggle with stiff fingers or a kinked neck or back after" + + " a few minutes on the road, this lightweight, aluminum bike" + + " alleviates those issues and allows you to enjoy the ride. From" + + " the ergonomic grips to the lumbar-supporting seat position, the" + + " Roll Low-Entry offers incredible comfort. The rear-inclined seat" + + " tube facilitates stability by allowing you to put a foot on the" + + " ground to balance at a stop, and the low step-over frame makes it" + + " accessible for all ability and mobility levels. The saddle is" + + " very soft, with a wide back to support your hip joints and a" + + " cutout in the center to redistribute that pressure. Rim brakes" + + " deliver satisfactory braking control, and the wide tires provide" + + " a smooth, stable ride on paved roads and gravel. Rack and fender" + + " mounts facilitate setting up the Roll Low-Entry as your preferred" + + " commuter, and the BMX-like handlebar offers space for mounting a" + + " flashlight, bell, or phone holder.", + Condition = "new", + }, + new + { + Brand = "nHill", + Model = "Summit", + Price = 1200M, + Description = "This budget mountain bike from nHill performs well both on bike" + + " paths and on the trail. The fork with 100mm of travel absorbs" + + " rough terrain. Fat Kenda Booster tires give you grip in corners" + + " and on wet trails. The Shimano Tourney drivetrain offered enough" + + " gears for finding a comfortable pace to ride uphill, and the" + + " Tektro hydraulic disc brakes break smoothly. Whether you want an" + + " affordable bike that you can take to work, but also take trail in" + + " mountains on the weekends or you’re just after a stable," + + " comfortable ride for the bike path, the Summit gives a good value" + + " for money.", + Condition = "new", + }, + new + { + Model = "ThrillCycle", + Brand = "BikeShind", + Price = 815M, + Description = "An artsy, retro-inspired bicycle that’s as functional as it is" + + " pretty: The ThrillCycle steel frame offers a smooth ride. A" + + " 9-speed drivetrain has enough gears for coasting in the city, but" + + " we wouldn’t suggest taking it to the mountains. Fenders protect" + + " you from mud, and a rear basket lets you transport groceries," + + " flowers and books. The ThrillCycle comes with a limited lifetime" + + " warranty, so this little guy will last you long past graduation.", + Condition = "refurbished", + }, + }; + + // STEP_START create_index + var schema = new Schema() + .AddTextField(new FieldName("$.Brand", "Brand")) + .AddTextField(new FieldName("$.Model", "Model")) + .AddTextField(new FieldName("$.Description", "Description")) + .AddNumericField(new FieldName("$.Price", "Price")) + .AddTagField(new FieldName("$.Condition", "Condition")); + + ft.Create( + "idx:bicycle", + new FTCreateParams().On(IndexDataType.JSON).Prefix("bicycle:"), + schema); + + for (int i = 0; i < bicycles.Length; i++) + { + json.Set($"bicycle:{i}", "$", bicycles[i]); + } + // STEP_END + + // STEP_START wildcard_query + var query1 = new Query("*"); + var res1 = ft.Search("idx:bicycle", query1).Documents; + Console.WriteLine(string.Join("\n", res1.Count())); + // Prints: Documents found: 10 + // STEP_END + // REMOVE_START + Assert.Equal(10, res1.Count()); + // REMOVE_END + + // STEP_START query_single_term + var query2 = new Query("@Model:Jigger"); + var res2 = ft.Search("idx:bicycle", query2).Documents; + Console.WriteLine(string.Join("\n", res2.Select(x => x["json"]))); + // Prints: {"Brand":"Moore PLC","Model":"Award Race","Price":3790.76, + // "Description":"This olive folding bike features a carbon frame + // and 27.5 inch wheels. This folding bike is perfect for compact + // storage and transportation.","Condition":"new"} + // STEP_END + // REMOVE_START + Assert.Single(res2); + Assert.Equal("bicycle:0", res2[0].Id); + // REMOVE_END + + // STEP_START query_single_term_and_num_range + var query3 = new Query("basic @Price:[500 1000]"); + var res3 = ft.Search("idx:bicycle", query3).Documents; + Console.WriteLine(string.Join("\n", res3.Select(x => x["json"]))); + // Prints: {"Brand":"Moore PLC","Model":"Award Race","Price":3790.76, + // "Description":"This olive folding bike features a carbon frame + // and 27.5 inch wheels. This folding bike is perfect for compact + // storage and transportation.","Condition":"new"} + // STEP_END + // REMOVE_START + Assert.Single(res3); + Assert.Equal("bicycle:5", res3[0].Id); + // REMOVE_END + + // STEP_START query_exact_matching + var query4 = new Query("@Brand:\"Noka Bikes\""); + var res4 = ft.Search("idx:bicycle", query4).Documents; + Console.WriteLine(string.Join("\n", res4.Select(x => x["json"]))); + // Prints: {"Brand":"Moore PLC","Model":"Award Race","Price":3790.76, + // "Description":"This olive folding bike features a carbon frame + // and 27.5 inch wheels. This folding bike is perfect for compact + // storage and transportation.","Condition":"new"} + // STEP_END + // REMOVE_START + Assert.Single(res4); + Assert.Equal("bicycle:4", res4[0].Id); + // REMOVE_END + + // STEP_START query_single_term_limit_fields + var query5 = new Query("@Model:Jigger").ReturnFields("Price"); + var res5 = ft.Search("idx:bicycle", query5).Documents; + Console.WriteLine(res5.First()["Price"]); + // Prints: 270 + // STEP_END + // REMOVE_START + Assert.Single(res5); + Assert.Equal("bicycle:0", res5[0].Id); + // REMOVE_END + + // STEP_START simple_aggregation + var request = new AggregationRequest("*").GroupBy( + "@Condition", Reducers.Count().As("Count")); + var result = ft.Aggregate("idx:bicycle", request); + + for (var i = 0; i < result.TotalResults; i++) + { + var row = result.GetRow(i); + Console.WriteLine($"{row["Condition"]} - {row["Count"]}"); + } + + // Prints: + // refurbished - 1 + // used - 5 + // new - 4 + // STEP_END + // REMOVE_START + Assert.Equal(3, result.TotalResults); + // REMOVE_END + } +} \ No newline at end of file diff --git a/local_examples/search_quickstart/redis-py/search_quickstart.py b/local_examples/search_quickstart/redis-py/search_quickstart.py new file mode 100644 index 0000000000..7201d9f560 --- /dev/null +++ b/local_examples/search_quickstart/redis-py/search_quickstart.py @@ -0,0 +1,423 @@ +# EXAMPLE: search_quickstart +# HIDE_START +""" +Code samples for document database quickstart pages: + https://redis.io/docs/latest/develop/get-started/document-database/ +""" + +import redis +import redis.commands.search.aggregation as aggregations +import redis.commands.search.reducers as reducers +from redis.commands.json.path import Path +from redis.commands.search.field import NumericField, TagField, TextField +from redis.commands.search.index_definition import IndexDefinition, IndexType +from redis.commands.search.query import Query + +# HIDE_END + +# STEP_START connect +r = redis.Redis(host="localhost", port=6379, db=0, decode_responses=True) +# STEP_END +# REMOVE_START +try: + r.ft("idx:bicycle").dropindex() +except Exception: + pass +# REMOVE_END +# STEP_START data_sample +bicycle = { + "brand": "Velorim", + "model": "Jigger", + "price": 270, + "description": ( + "Small and powerful, the Jigger is the best ride " + "for the smallest of tikes! This is the tiniest " + "kids’ pedal bike on the market available without" + " a coaster brake, the Jigger is the vehicle of " + "choice for the rare tenacious little rider " + "raring to go." + ), + "condition": "new", +} +# STEP_END + +bicycles = [ + bicycle, + { + "brand": "Bicyk", + "model": "Hillcraft", + "price": 1200, + "description": ( + "Kids want to ride with as little weight as possible." + " Especially on an incline! They may be at the age " + 'when a 27.5" wheel bike is just too clumsy coming ' + 'off a 24" bike. The Hillcraft 26 is just the solution' + " they need!" + ), + "condition": "used", + }, + { + "brand": "Nord", + "model": "Chook air 5", + "price": 815, + "description": ( + "The Chook Air 5 gives kids aged six years and older " + "a durable and uberlight mountain bike for their first" + " experience on tracks and easy cruising through forests" + " and fields. The lower top tube makes it easy to mount" + " and dismount in any situation, giving your kids greater" + " safety on the trails." + ), + "condition": "used", + }, + { + "brand": "Eva", + "model": "Eva 291", + "price": 3400, + "description": ( + "The sister company to Nord, Eva launched in 2005 as the" + " first and only women-dedicated bicycle brand. Designed" + " by women for women, allEva bikes are optimized for the" + " feminine physique using analytics from a body metrics" + " database. If you like 29ers, try the Eva 291. It’s a " + "brand new bike for 2022.. This full-suspension, " + "cross-country ride has been designed for velocity. The" + " 291 has 100mm of front and rear travel, a superlight " + "aluminum frame and fast-rolling 29-inch wheels. Yippee!" + ), + "condition": "used", + }, + { + "brand": "Noka Bikes", + "model": "Kahuna", + "price": 3200, + "description": ( + "Whether you want to try your hand at XC racing or are " + "looking for a lively trail bike that's just as inspiring" + " on the climbs as it is over rougher ground, the Wilder" + " is one heck of a bike built specifically for short women." + " Both the frames and components have been tweaked to " + "include a women’s saddle, different bars and unique " + "colourway." + ), + "condition": "used", + }, + { + "brand": "Breakout", + "model": "XBN 2.1 Alloy", + "price": 810, + "description": ( + "The XBN 2.1 Alloy is our entry-level road bike – but that’s" + " not to say that it’s a basic machine. With an internal " + "weld aluminium frame, a full carbon fork, and the slick-shifting" + " Claris gears from Shimano’s, this is a bike which doesn’t" + " break the bank and delivers craved performance." + ), + "condition": "new", + }, + { + "brand": "ScramBikes", + "model": "WattBike", + "price": 2300, + "description": ( + "The WattBike is the best e-bike for people who still feel young" + " at heart. It has a Bafang 1000W mid-drive system and a 48V" + " 17.5AH Samsung Lithium-Ion battery, allowing you to ride for" + " more than 60 miles on one charge. It’s great for tackling hilly" + " terrain or if you just fancy a more leisurely ride. With three" + " working modes, you can choose between E-bike, assisted bicycle," + " and normal bike modes." + ), + "condition": "new", + }, + { + "brand": "Peaknetic", + "model": "Secto", + "price": 430, + "description": ( + "If you struggle with stiff fingers or a kinked neck or back after" + " a few minutes on the road, this lightweight, aluminum bike" + " alleviates those issues and allows you to enjoy the ride. From" + " the ergonomic grips to the lumbar-supporting seat position, the" + " Roll Low-Entry offers incredible comfort. The rear-inclined seat" + " tube facilitates stability by allowing you to put a foot on the" + " ground to balance at a stop, and the low step-over frame makes it" + " accessible for all ability and mobility levels. The saddle is" + " very soft, with a wide back to support your hip joints and a" + " cutout in the center to redistribute that pressure. Rim brakes" + " deliver satisfactory braking control, and the wide tires provide" + " a smooth, stable ride on paved roads and gravel. Rack and fender" + " mounts facilitate setting up the Roll Low-Entry as your preferred" + " commuter, and the BMX-like handlebar offers space for mounting a" + " flashlight, bell, or phone holder." + ), + "condition": "new", + }, + { + "brand": "nHill", + "model": "Summit", + "price": 1200, + "description": ( + "This budget mountain bike from nHill performs well both on bike" + " paths and on the trail. The fork with 100mm of travel absorbs" + " rough terrain. Fat Kenda Booster tires give you grip in corners" + " and on wet trails. The Shimano Tourney drivetrain offered enough" + " gears for finding a comfortable pace to ride uphill, and the" + " Tektro hydraulic disc brakes break smoothly. Whether you want an" + " affordable bike that you can take to work, but also take trail in" + " mountains on the weekends or you’re just after a stable," + " comfortable ride for the bike path, the Summit gives a good value" + " for money." + ), + "condition": "new", + }, + { + "model": "ThrillCycle", + "brand": "BikeShind", + "price": 815, + "description": ( + "An artsy, retro-inspired bicycle that’s as functional as it is" + " pretty: The ThrillCycle steel frame offers a smooth ride. A" + " 9-speed drivetrain has enough gears for coasting in the city, but" + " we wouldn’t suggest taking it to the mountains. Fenders protect" + " you from mud, and a rear basket lets you transport groceries," + " flowers and books. The ThrillCycle comes with a limited lifetime" + " warranty, so this little guy will last you long past graduation." + ), + "condition": "refurbished", + }, +] + +# STEP_START create_index +schema = ( + TextField("$.brand", as_name="brand"), + TextField("$.model", as_name="model"), + TextField("$.description", as_name="description"), + NumericField("$.price", as_name="price"), + TagField("$.condition", as_name="condition"), +) + +index = r.ft("idx:bicycle") +index.create_index( + schema, + definition=IndexDefinition(prefix=["bicycle:"], index_type=IndexType.JSON), +) +for bid, bicycle in enumerate(bicycles): + r.json().set(f"bicycle:{bid}", Path.root_path(), bicycle) +# STEP_END + + +# STEP_START wildcard_query +res = index.search(Query("*")) +print("Documents found:", res.total) +# >>> Documents found: 10 +# STEP_END +# REMOVE_START +assert res.total == 10 +# REMOVE_END + +# STEP_START query_single_term +res = index.search(Query("@model:Jigger")) +print(res) +# >>> Result{1 total, docs: [ +# Document { +# 'id': 'bicycle:0', +# 'payload': None, +# 'json': '{ +# "brand":"Velorim", +# "model":"Jigger", +# "price":270, +# ... +# "condition":"new" +# }' +# }]} +# STEP_END +# REMOVE_START +assert res.docs[0].id == "bicycle:0" +# REMOVE_END + +# STEP_START query_single_term_limit_fields +res = index.search(Query("@model:Jigger").return_field("$.price", as_field="price")) +print(res) +# >>> [Document {'id': 'bicycle:0', 'payload': None, 'price': '270'}] +# STEP_END +# REMOVE_START +assert res.docs[0].id == "bicycle:0" +# REMOVE_END + +# STEP_START query_single_term_and_num_range +res = index.search(Query("basic @price:[500 1000]")) +print(res) +# >>> Result{1 total, docs: [ +# Document { +# 'id': 'bicycle:5', +# 'payload': None, +# 'json': '{ +# "brand":"Breakout", +# "model":"XBN 2.1 Alloy", +# "price":810, +# ... +# "condition":"new" +# }' +# }]} +# STEP_END +# REMOVE_START +assert res.docs[0].id == "bicycle:5" +# REMOVE_END + +# STEP_START query_exact_matching +res = index.search(Query('@brand:"Noka Bikes"')) +print(res) +# >>> Result{1 total, docs: [ +# Document { +# 'id': 'bicycle:4', +# 'payload': None, +# 'json': '{ +# "brand":"Noka Bikes", +# "model":"Kahuna", +# "price":3200, +# ... +# "condition":"used" +# }' +# }]} +# STEP_END +# REMOVE_START +assert res.docs[0].id == "bicycle:4" +# REMOVE_END + +# STEP_START query_fuzzy_matching +res = index.search( + Query("@description:%analitics%").dialect( # Note the typo in the word "analytics" + 2 + ) +) +print(res) +# >>> Result{1 total, docs: [ +# Document { +# 'id': 'bicycle:3', +# 'payload': None, +# 'json': '{ +# "brand":"Eva", +# "model":"Eva 291", +# "price":3400, +# "description":"...using analytics from a body metrics database...", +# "condition":"used" +# }' +# }]} +# STEP_END +# REMOVE_START +assert res.docs[0].id == "bicycle:3" +# REMOVE_END + +# STEP_START query_fuzzy_matching_level2 +res = index.search( + Query("@description:%%analitycs%%").dialect( # Note 2 typos in the word "analytics" + 2 + ) +) +print(res) +# >>> Result{1 total, docs: [ +# Document { +# 'id': 'bicycle:3', +# 'payload': None, +# 'json': '{ +# "brand":"Eva", +# "model":"Eva 291", +# "price":3400, +# "description":"...using analytics from a body metrics database...", +# "condition":"used" +# }' +# }]} +# STEP_END +# REMOVE_START +assert res.docs[0].id == "bicycle:3" +# REMOVE_END + +# STEP_START query_prefix_matching +res = index.search(Query("@model:hill*")) +print(res) +# >>> Result{1 total, docs: [ +# Document { +# 'id': 'bicycle:1', +# 'payload': None, +# 'json': '{ +# "brand":"Bicyk", +# "model":"Hillcraft", +# "price":1200, +# ... +# "condition":"used" +# }' +# }]} +# STEP_END +# REMOVE_START +assert res.docs[0].id == "bicycle:1" +# REMOVE_END + +# STEP_START query_suffix_matching +res = index.search(Query("@model:*bike")) +print(res) +# >>> Result{1 total, docs: [ +# Document { +# 'id': 'bicycle:6', +# 'payload': None, +# 'json': '{ +# "brand":"ScramBikes", +# "model":"WattBike", +# "price":2300, +# ... +# "condition":"new" +# }' +# }]} +# STEP_END +# REMOVE_START +assert res.docs[0].id == "bicycle:6" +# REMOVE_END + +# STEP_START query_wildcard_matching +res = index.search(Query("w'H?*craft'").dialect(2)) +print(res.docs[0].json) +# >>> { +# "brand":"Bicyk", +# "model":"Hillcraft", +# "price":1200, +# ... +# "condition":"used" +# } +# STEP_END +# REMOVE_START +assert res.docs[0].id == "bicycle:1" +# REMOVE_END + + +# STEP_START query_with_default_scorer +res = index.search(Query("mountain").with_scores()) +for sr in res.docs: + print(f"{sr.id}: score={sr.score}") +# STEP_END +# REMOVE_START +assert res.total == 3 +# REMOVE_END + +# STEP_START query_with_bm25_scorer +res = index.search(Query("mountain").with_scores().scorer("BM25")) +for sr in res.docs: + print(f"{sr.id}: score={sr.score}") +# STEP_END +# REMOVE_START +assert res.total == 3 +assert res.docs[0].score == res.docs[1].score +# REMOVE_END + +# STEP_START simple_aggregation +req = aggregations.AggregateRequest("*").group_by( + "@condition", reducers.count().alias("count") +) +res = index.aggregate(req).rows +print(res) +# >>> [['condition', 'refurbished', 'count', '1'], +# ['condition', 'used', 'count', '4'], +# ['condition', 'new', 'count', '5']] +# STEP_END +# REMOVE_START +assert len(res) == 3 +# REMOVE_END From b2fcf89a020adb7248d905195a78b9f91cfd176b Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Thu, 9 Jul 2026 13:28:32 +0100 Subject: [PATCH 14/28] DOC-6823 make geoindex GEO/GEOSHAPE examples self-contained and runnable Each of the two sections on the geoindex page is now a single self-contained clients-example box that creates its index, adds its JSON documents, and runs its query, so the interactive CLI works with no prereq wiring. Self-contained was chosen over a shared "create both indexes" prereq because each query returns a single deterministic result (no SORTBY/ordering issue) and because the client sources keep the two index creations non-adjacent -- a shared prereq would have forced relocating create_gshape_idx in four overrides, whereas each section's three steps are already consecutive, so the merge is marker-only. Delivered as four local overrides (redis-py/go-redis/jedis/nredisstack; there is no node-redis upstream for this page) that delete only interior STEP markers. Verified on Redis 8.8: each box runs standalone on a fresh DB and returns the documented product:46885 (GEO) / shape:4 (GEOSHAPE). Learned: geoindex's two FT.SEARCH queries each return a single deterministic doc (no multi-result ordering issue), so full create+add+query self-contained boxes work with no prereq/needs_prereq Rejected: a shared "create both indexes" prereq block | the client sources keep the two index creations non-adjacent, so it would force relocating create_gshape_idx in 4 overrides, whereas self-contained per-section needs only marker-only merges Directive: the four geoindex overrides are marker-only merges of the upstream client sources -- re-apply the per-section STEP merge if you re-sync from upstream, don't hand-edit the code Ticket: DOC-6823 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ai/search-and-query/indexing/geoindex.md | 49 ++--- .../geoindex/go-redis/geo_index_test.go | 201 +++++++++++++++++ .../geoindex/jedis/GeoIndexExample.java | 173 +++++++++++++++ .../geoindex/nredisstack/GeoIndexExample.cs | 202 ++++++++++++++++++ local_examples/geoindex/redis-py/geo_index.py | 145 +++++++++++++ 5 files changed, 737 insertions(+), 33 deletions(-) create mode 100644 local_examples/geoindex/go-redis/geo_index_test.go create mode 100644 local_examples/geoindex/jedis/GeoIndexExample.java create mode 100644 local_examples/geoindex/nredisstack/GeoIndexExample.cs create mode 100644 local_examples/geoindex/redis-py/geo_index.py diff --git a/content/develop/ai/search-and-query/indexing/geoindex.md b/content/develop/ai/search-and-query/indexing/geoindex.md index 10aa6bfed0..05e0436932 100644 --- a/content/develop/ai/search-and-query/indexing/geoindex.md +++ b/content/develop/ai/search-and-query/indexing/geoindex.md @@ -34,29 +34,21 @@ reference page for a full description of both types. ## `GEO` -The following command creates a `GEO` index for JSON objects that contain -the geospatial data in a field called `location`: - -{{< clients-example set="geoindex" step="create_geo_idx" description="Foundational: Create an index for geospatial point data using the GEO field type when you need to store and query longitude-latitude coordinates" difficulty="beginner" >}} +The following example creates a `GEO` index for JSON objects that store +geospatial data in a field called `location`, adds two product documents (any +JSON object with the `product:` prefix and a `location` field is indexed +automatically), and then queries for products within a 100 mile radius of +Colorado Springs (Longitude=-104.800644, Latitude=38.846127). This returns only +the location in Denver, but a radius of 200 miles would also include the +location in Fort Collins: + +{{< clients-example set="geoindex" step="create_geo_idx" description="GEO index: create a GEO index, add geospatial point documents, then run a radius query to find nearby locations" difficulty="beginner" >}} > FT.CREATE productidx ON JSON PREFIX 1 product: SCHEMA $.location AS location GEO OK -{{< /clients-example >}} - -If you now add JSON objects with the `product:` prefix and a `location` field, -they will be added to the index automatically: - -{{< clients-example set="geoindex" step="add_geo_json" description="Foundational: Add JSON documents with geospatial point data to an indexed field when you need to populate the index with location information" difficulty="beginner" >}} > JSON.SET product:46885 $ '{"description": "Navy Blue Slippers","price": 45.99,"city": "Denver","location": "-104.991531, 39.742043"}' OK > JSON.SET product:46886 $ '{"description": "Bright Green Socks","price": 25.50,"city": "Fort Collins","location": "-105.0618814,40.5150098"}' OK -{{< /clients-example >}} - -The query below finds products within a 100 mile radius of Colorado Springs -(Longitude=-104.800644, Latitude=38.846127). This returns only the location in -Denver, but a radius of 200 miles would also include the location in Fort Collins: - -{{< clients-example set="geoindex" step="geo_query" description="GEO radius query: Query geospatial points within a radius using center coordinates and distance when you need to find nearby locations" difficulty="beginner" try_it="false" >}} > FT.SEARCH productidx '@location:[-104.800644 38.846127 100 mi]' 1) "1" 2) "product:46885" @@ -69,20 +61,18 @@ for more information about the available options. ## `GEOSHAPE` -The following command creates an index for JSON objects that include +The following example creates an index for JSON objects that include geospatial data in a field called `geom`. The `FLAT` option at the end of the field definition specifies Cartesian coordinates instead of -the default spherical geographical coordinates. Use `SPHERICAL` in -place of `FLAT` to choose the coordinate space explicitly. +the default spherical geographical coordinates (use `SPHERICAL` in +place of `FLAT` to choose the coordinate space explicitly). It then adds +several shapes using the `shape:` prefix and runs a query that returns any +shapes within the boundary of the green square but omits the green square +itself: -{{< clients-example set="geoindex" step="create_gshape_idx" description="Foundational: Create an index for geometric shapes using the GEOSHAPE field type when you need to store and query polygons and points" difficulty="intermediate" >}} +{{< clients-example set="geoindex" step="create_gshape_idx" description="GEOSHAPE index: create a GEOSHAPE index, add polygon and point documents, then run a WITHIN query to test shape containment" difficulty="intermediate" >}} > FT.CREATE geomidx ON JSON PREFIX 1 shape: SCHEMA $.name AS name TEXT $.geom AS geom GEOSHAPE FLAT OK -{{< /clients-example >}} - -Use the `shape:` prefix for the JSON objects to add them to the index: - -{{< clients-example set="geoindex" step="add_gshape_json" description="Foundational: Add JSON documents with geometric shape data to an indexed field when you need to populate the index with polygon and point information" difficulty="intermediate" >}} > JSON.SET shape:1 $ '{"name": "Green Square", "geom": "POLYGON ((1 1, 1 3, 3 3, 3 1, 1 1))"}' OK > JSON.SET shape:2 $ '{"name": "Red Rectangle", "geom": "POLYGON ((2 2.5, 2 3.5, 3.5 3.5, 3.5 2.5, 2 2.5))"}' @@ -91,13 +81,6 @@ OK OK > JSON.SET shape:4 $ '{"name": "Purple Point", "geom": "POINT (2 2)"}' OK -{{< /clients-example >}} - -You can now run various geospatial queries against the index. For -example, the query below returns any shapes within the boundary -of the green square but omits the green square itself: - -{{< clients-example set="geoindex" step="gshape_query" description="GEOSHAPE query: Query geometric shapes using spatial operators like WITHIN to find shapes with specific geometric relationships when you need to test shape containment" difficulty="intermediate" try_it="false" >}} > FT.SEARCH geomidx "(-@name:(Green Square) @geom:[WITHIN $qshape])" PARAMS 2 qshape "POLYGON ((1 1, 1 3, 3 3, 3 1, 1 1))" RETURN 1 name DIALECT 2 1) (integer) 1 diff --git a/local_examples/geoindex/go-redis/geo_index_test.go b/local_examples/geoindex/go-redis/geo_index_test.go new file mode 100644 index 0000000000..b503451b0c --- /dev/null +++ b/local_examples/geoindex/go-redis/geo_index_test.go @@ -0,0 +1,201 @@ +// EXAMPLE: geoindex +// HIDE_START +package example_commands_test + +import ( + "context" + "fmt" + + "github.com/redis/go-redis/v9" +) + +// HIDE_END + +func ExampleClient_geoindex() { + ctx := context.Background() + + rdb := redis.NewClient(&redis.Options{ + Addr: "localhost:6379", + Password: "", // no password docs + DB: 0, // use default DB + Protocol: 2, + }) + + // REMOVE_START + // make sure we are working with fresh database + rdb.FlushDB(ctx) + rdb.FTDropIndex(ctx, "productidx") + rdb.FTDropIndex(ctx, "geomidx") + rdb.Del(ctx, "product:46885", "product:46886", "shape:1", "shape:2", "shape:3", "shape:4") + // REMOVE_END + + // STEP_START create_geo_idx + geoCreateResult, err := rdb.FTCreate(ctx, + "productidx", + &redis.FTCreateOptions{ + OnJSON: true, + Prefix: []interface{}{"product:"}, + }, + &redis.FieldSchema{ + FieldName: "$.location", + As: "location", + FieldType: redis.SearchFieldTypeGeo, + }, + ).Result() + + if err != nil { + panic(err) + } + + fmt.Println(geoCreateResult) // >>> OK + + prd46885 := map[string]interface{}{ + "description": "Navy Blue Slippers", + "price": 45.99, + "city": "Denver", + "location": "-104.991531, 39.742043", + } + + gjResult1, err := rdb.JSONSet(ctx, "product:46885", "$", prd46885).Result() + + if err != nil { + panic(err) + } + + fmt.Println(gjResult1) // >>> OK + + prd46886 := map[string]interface{}{ + "description": "Bright Green Socks", + "price": 25.50, + "city": "Fort Collins", + "location": "-105.0618814,40.5150098", + } + + gjResult2, err := rdb.JSONSet(ctx, "product:46886", "$", prd46886).Result() + + if err != nil { + panic(err) + } + + fmt.Println(gjResult2) // >>> OK + + geoQueryResult, err := rdb.FTSearch(ctx, "productidx", + "@location:[-104.800644 38.846127 100 mi]", + ).Result() + + if err != nil { + panic(err) + } + + fmt.Println(geoQueryResult) + // >>> {1 [{product:46885... + // STEP_END + + // STEP_START create_gshape_idx + geomCreateResult, err := rdb.FTCreate(ctx, "geomidx", + &redis.FTCreateOptions{ + OnJSON: true, + Prefix: []interface{}{"shape:"}, + }, + &redis.FieldSchema{ + FieldName: "$.name", + As: "name", + FieldType: redis.SearchFieldTypeText, + }, + &redis.FieldSchema{ + FieldName: "$.geom", + As: "geom", + FieldType: redis.SearchFieldTypeGeoShape, + GeoShapeFieldType: "FLAT", + }, + ).Result() + + if err != nil { + panic(err) + } + + fmt.Println(geomCreateResult) // >>> OK + + shape1 := map[string]interface{}{ + "name": "Green Square", + "geom": "POLYGON ((1 1, 1 3, 3 3, 3 1, 1 1))", + } + + gmjResult1, err := rdb.JSONSet(ctx, "shape:1", "$", shape1).Result() + + if err != nil { + panic(err) + } + + fmt.Println(gmjResult1) // >>> OK + + shape2 := map[string]interface{}{ + "name": "Red Rectangle", + "geom": "POLYGON ((2 2.5, 2 3.5, 3.5 3.5, 3.5 2.5, 2 2.5))", + } + + gmjResult2, err := rdb.JSONSet(ctx, "shape:2", "$", shape2).Result() + + if err != nil { + panic(err) + } + + fmt.Println(gmjResult2) // >>> OK + + shape3 := map[string]interface{}{ + "name": "Blue Triangle", + "geom": "POLYGON ((3.5 1, 3.75 2, 4 1, 3.5 1))", + } + + gmjResult3, err := rdb.JSONSet(ctx, "shape:3", "$", shape3).Result() + + if err != nil { + panic(err) + } + + fmt.Println(gmjResult3) // >>> OK + + shape4 := map[string]interface{}{ + "name": "Purple Point", + "geom": "POINT (2 2)", + } + + gmjResult4, err := rdb.JSONSet(ctx, "shape:4", "$", shape4).Result() + + if err != nil { + panic(err) + } + + fmt.Println(gmjResult4) // >>> OK + + geomQueryResult, err := rdb.FTSearchWithArgs(ctx, "geomidx", + "(-@name:(Green Square) @geom:[WITHIN $qshape])", + &redis.FTSearchOptions{ + Params: map[string]interface{}{ + "qshape": "POLYGON ((1 1, 1 3, 3 3, 3 1, 1 1))", + }, + DialectVersion: 4, + Limit: 1, + }, + ).Result() + + if err != nil { + panic(err) + } + + fmt.Println(geomQueryResult) + // >>> {1 [{shape:4... + // STEP_END + + // Output: + // OK + // OK + // OK + // {1 [{product:46885 map[$:{"city":"Denver","description":"Navy Blue Slippers","location":"-104.991531, 39.742043","price":45.99}] }] []} + // OK + // OK + // OK + // OK + // OK + // {1 [{shape:4 map[$:[{"geom":"POINT (2 2)","name":"Purple Point"}]] }] []} +} diff --git a/local_examples/geoindex/jedis/GeoIndexExample.java b/local_examples/geoindex/jedis/GeoIndexExample.java new file mode 100644 index 0000000000..779f82dc26 --- /dev/null +++ b/local_examples/geoindex/jedis/GeoIndexExample.java @@ -0,0 +1,173 @@ +// EXAMPLE: geoindex +// REMOVE_START +package io.redis.examples; + +import org.junit.jupiter.api.Test; +// REMOVE_END + +// HIDE_START +import org.json.JSONObject; + +import redis.clients.jedis.RedisClient; +import redis.clients.jedis.json.Path2; +import redis.clients.jedis.search.Document; +import redis.clients.jedis.search.FTCreateParams; +import redis.clients.jedis.search.FTSearchParams; +import redis.clients.jedis.search.IndexDataType; +import redis.clients.jedis.search.schemafields.*; +import redis.clients.jedis.search.schemafields.GeoShapeField.CoordinateSystem; +import redis.clients.jedis.search.SearchResult; +import redis.clients.jedis.exceptions.JedisDataException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +// HIDE_END + +// HIDE_START +public class GeoIndexExample { + + @Test + public void run() { + RedisClient jedis = RedisClient.create("redis://localhost:6379"); + //REMOVE_START + // Clear any keys here before using them in tests. + try { + jedis.ftDropIndex("productidx"); + } catch (JedisDataException j) {} + + try { + jedis.ftDropIndex("geomidx"); + } catch (JedisDataException j) {} + + jedis.del("product:46885", "product:46886", "shape:1", "shape:2", "shape:3", "shape:4"); + //REMOVE_END +// HIDE_END + + // STEP_START create_geo_idx + SchemaField[] geoSchema = { + GeoField.of("$.location").as("location") + }; + + String geoIdxCreateResult = jedis.ftCreate("productidx", + FTCreateParams.createParams() + .on(IndexDataType.JSON) + .addPrefix("product:"), + geoSchema + ); + // REMOVE_START + assertEquals("OK", geoIdxCreateResult); + // REMOVE_END + + JSONObject prd46885 = new JSONObject() + .put("description", "Navy Blue Slippers") + .put("price", 45.99) + .put("city", "Denver") + .put("location", "-104.991531, 39.742043"); + + String jsonAddResult1 = jedis.jsonSet("product:46885", new Path2("$"), prd46885); + System.out.println(jsonAddResult1); // >>> OK + + JSONObject prd46886 = new JSONObject() + .put("description", "Bright Green Socks") + .put("price", 25.50) + .put("city", "Fort Collins") + .put("location", "-105.0618814,40.5150098"); + + String jsonAddResult2 = jedis.jsonSet("product:46886", new Path2("$"), prd46886); + System.out.println(jsonAddResult2); // >>> OK + // REMOVE_START + assertEquals("OK", jsonAddResult1); + assertEquals("OK", jsonAddResult2); + // REMOVE_END + + SearchResult geoResult = jedis.ftSearch("productidx", + "@location:[-104.800644 38.846127 100 mi]" + ); + + System.out.println(geoResult.getTotalResults()); // >>> 1 + + for (Document doc: geoResult.getDocuments()) { + System.out.println(doc.getId()); + } + // >>> product:46885 + // STEP_END + // REMOVE_START + assertEquals("OK", jsonAddResult1); + assertEquals("OK", jsonAddResult2); + assertEquals("product:46885", geoResult.getDocuments().get(0).getId()); + // REMOVE_END + + // STEP_START create_gshape_idx + SchemaField[] geomSchema = { + TextField.of("$.name").as("name"), + GeoShapeField.of("$.geom", CoordinateSystem.FLAT).as("geom") + }; + + String geomIndexCreateResult = jedis.ftCreate("geomidx", + FTCreateParams.createParams() + .on(IndexDataType.JSON) + .addPrefix("shape"), + geomSchema + ); + System.out.println(geomIndexCreateResult); // >>> OK + // REMOVE_START + assertEquals("OK", geomIndexCreateResult); + // REMOVE_END + + JSONObject shape1 = new JSONObject() + .put("name", "Green Square") + .put("geom", "POLYGON ((1 1, 1 3, 3 3, 3 1, 1 1))"); + + String gmJsonRes1 = jedis.jsonSet("shape:1", new Path2("$"), shape1); + System.out.println(gmJsonRes1); // >>> OK + + JSONObject shape2 = new JSONObject() + .put("name", "Red Rectangle") + .put("geom", "POLYGON ((2 2.5, 2 3.5, 3.5 3.5, 3.5 2.5, 2 2.5))"); + + String gmJsonRes2 = jedis.jsonSet("shape:2", new Path2("$"), shape2); + System.out.println(gmJsonRes2); // >>> OK + + JSONObject shape3 = new JSONObject() + .put("name", "Blue Triangle") + .put("geom", "POLYGON ((3.5 1, 3.75 2, 4 1, 3.5 1))"); + + String gmJsonRes3 = jedis.jsonSet("shape:3", new Path2("$"), shape3); + System.out.println(gmJsonRes3); // >>> OK + + JSONObject shape4 = new JSONObject() + .put("name", "Purple Point") + .put("geom", "POINT (2 2)"); + + String gmJsonRes4 = jedis.jsonSet("shape:4", new Path2("$"), shape4); + System.out.println(gmJsonRes4); // >>> OK + // REMOVE_START + assertEquals("OK", gmJsonRes1); + assertEquals("OK", gmJsonRes2); + assertEquals("OK", gmJsonRes3); + assertEquals("OK", gmJsonRes4); + // REMOVE_END + + SearchResult geomResult = jedis.ftSearch("geomidx", + "(-@name:(Green Square) @geom:[WITHIN $qshape])", + FTSearchParams.searchParams() + .addParam("qshape", "POLYGON ((1 1, 1 3, 3 3, 3 1, 1 1))") + .dialect(4) + .limit(0, 1) + ); + System.out.println(geomResult.getTotalResults()); // >>> 1 + + for (Document doc: geomResult.getDocuments()) { + System.out.println(doc.getId()); + } + // shape:4 + // STEP_END + // REMOVE_START + assertEquals(1, geomResult.getTotalResults()); + assertEquals("shape:4", geomResult.getDocuments().get(0).getId()); + // REMOVE_END + // HIDE_START + + jedis.close(); + } +} +// HIDE_END diff --git a/local_examples/geoindex/nredisstack/GeoIndexExample.cs b/local_examples/geoindex/nredisstack/GeoIndexExample.cs new file mode 100644 index 0000000000..18c8940d55 --- /dev/null +++ b/local_examples/geoindex/nredisstack/GeoIndexExample.cs @@ -0,0 +1,202 @@ +// EXAMPLE: geoindex + +// STEP_START import +using NRedisStack.RedisStackCommands; +using NRedisStack.Search; +using NRedisStack.Search.Literals.Enums; +using StackExchange.Redis; +// STEP_END + +// REMOVE_START +using NRedisStack.Tests; + +namespace Doc; + +[Collection("DocsTests")] +// REMOVE_END + +// HIDE_START +public class GeoIndexExample +// REMOVE_START +: AbstractNRedisStackTest, IDisposable +// REMOVE_END +{ + // REMOVE_START + public GeoIndexExample(EndpointsFixture fixture) : base(fixture) { } + + [Fact] + // REMOVE_END + public void run() + { + //REMOVE_START + // This is needed because we're constructing ConfigurationOptions in the test before calling GetConnection + SkipIfTargetConnectionDoesNotExist(EndpointsFixture.Env.Standalone); + var _ = GetCleanDatabase(EndpointsFixture.Env.Standalone); + //REMOVE_END + var muxer = ConnectionMultiplexer.Connect("localhost:6379"); + var db = muxer.GetDatabase(); + //REMOVE_START + // Clear any keys here before using them in tests. + db.KeyDelete(new RedisKey[] { "product:46885", "product:46886", "shape:1", "shape:2", "shape:3", "shape:4" }); + try { db.FT().DropIndex("productidx"); } catch { } + try { db.FT().DropIndex("geomidx"); } catch { } + //REMOVE_END + // HIDE_END + + // STEP_START create_geo_idx + Schema geoSchema = new Schema() + .AddGeoField(new FieldName("$.location", "location")); + + bool geoCreateResult = db.FT().Create( + "productidx", + new FTCreateParams() + .On(IndexDataType.JSON) + .Prefix("product:"), + geoSchema + ); + Console.WriteLine(geoCreateResult); // >>> True + // REMOVE_START + Assert.True(geoCreateResult); + // REMOVE_END + + var product46885 = new + { + description = "Navy Blue Slippers", + price = 45.99, + city = "Denver", + location = "-104.991531, 39.742043" + }; + + bool gjAddResult1 = db.JSON().Set("product:46885", "$", product46885); + Console.WriteLine(gjAddResult1); // >>> True + + var product46886 = new + { + description = "Bright Green Socks", + price = 25.50, + city = "Fort Collins", + location = "-105.0618814,40.5150098" + }; + + bool gjAddResult2 = db.JSON().Set("product:46886", "$", product46886); + Console.WriteLine(gjAddResult2); // >>> True + // REMOVE_START + Assert.True(gjAddResult1); + Assert.True(gjAddResult2); + // REMOVE_END + + SearchResult geoQueryResult = db.FT().Search( + "productidx", + new Query("@location:[-104.800644 38.846127 100 mi]") + ); + Console.WriteLine(geoQueryResult.Documents.Count); // >>> 1 + + Console.WriteLine( + string.Join(", ", geoQueryResult.Documents.Select(x => x["json"])) + ); + // >>> {"description":"Navy Blue Slippers","price":45.99,"city":"Denver"... + // STEP_END + // REMOVE_START + Assert.Single(geoQueryResult.Documents); + Assert.Equal( + "{\"description\":\"Navy Blue Slippers\",\"price\":45.99,\"city\":\"Denver\",\"location\":\"-104.991531, 39.742043\"}", + string.Join(", ", geoQueryResult.Documents.Select(x => x["json"])) + ); + // REMOVE_END + + // STEP_START create_gshape_idx + Version version = muxer.GetServer("localhost:6379").Version; + if (version.Major >= 7) + { + Schema geomSchema = new Schema() + .AddGeoShapeField( + new FieldName("$.geom", "geom"), + Schema.GeoShapeField.CoordinateSystem.FLAT + ) + .AddTextField(new FieldName("$.name", "name")); + + bool geomCreateResult = db.FT().Create( + "geomidx", + new FTCreateParams() + .On(IndexDataType.JSON) + .Prefix("shape:"), + geomSchema + ); + // REMOVE_START + Assert.True(geomCreateResult); + // REMOVE_END + Console.WriteLine(geomCreateResult); // >>> True + } + + + var shape1 = new + { + name = "Green Square", + geom = "POLYGON ((1 1, 1 3, 3 3, 3 1, 1 1))" + }; + + bool gmJsonRes1 = db.JSON().Set("shape:1", "$", shape1); + Console.WriteLine(gmJsonRes1); // >>> True + + var shape2 = new + { + name = "Red Rectangle", + geom = "POLYGON ((2 2.5, 2 3.5, 3.5 3.5, 3.5 2.5, 2 2.5))" + }; + + bool gmJsonRes2 = db.JSON().Set("shape:2", "$", shape2); + Console.WriteLine(gmJsonRes1); // >>> True + + var shape3 = new + { + name = "Blue Triangle", + geom = "POLYGON ((3.5 1, 3.75 2, 4 1, 3.5 1))" + }; + + bool gmJsonRes3 = db.JSON().Set("shape:3", "$", shape3); + Console.WriteLine(gmJsonRes3); // >>> True + + var shape4 = new + { + name = "Purple Point", + geom = "POINT (2 2)" + }; + + bool gmJsonRes4 = db.JSON().Set("shape:4", "$", shape4); + Console.WriteLine(gmJsonRes3); // >>> True + // REMOVE_START + Assert.True(gmJsonRes1); + Assert.True(gmJsonRes2); + Assert.True(gmJsonRes3); + Assert.True(gmJsonRes4); + // REMOVE_END + + if (version.Major >= 7) + { + SearchResult geomQueryResult = db.FT().Search( + "geomidx", + new Query("(-@name:(Green Square) @geom:[WITHIN $qshape])") + .AddParam("qshape", "POLYGON ((1 1, 1 3, 3 3, 3 1, 1 1))") + .Limit(0, 1) + ); + + Console.WriteLine(geomQueryResult.Documents.Count); // >>> 1 + var res = string.Join(", ", geomQueryResult.Documents.Select(x => x["json"])); + + Console.WriteLine( + string.Join(", ", geomQueryResult.Documents.Select(x => x["json"])) + ); + // >>> {"name":"Purple Point","geom":"POINT (2 2)"} + // REMOVE_START + Assert.Single(geomQueryResult.Documents); + Assert.Equal( + "{\"name\":\"Purple Point\",\"geom\":\"POINT (2 2)\"}", + string.Join(", ", geomQueryResult.Documents.Select(x => x["json"])) + ); + // REMOVE_END + } + // STEP_END + // HIDE_START + } +} +// HIDE_END diff --git a/local_examples/geoindex/redis-py/geo_index.py b/local_examples/geoindex/redis-py/geo_index.py new file mode 100644 index 0000000000..87a2febf11 --- /dev/null +++ b/local_examples/geoindex/redis-py/geo_index.py @@ -0,0 +1,145 @@ +# EXAMPLE: geoindex +import redis +from redis.commands.json.path import Path +from redis.commands.search.field import TextField, GeoField, GeoShapeField +from redis.commands.search.indexDefinition import IndexDefinition, IndexType +from redis.commands.search.query import Query + +r = redis.Redis() +# REMOVE_START +try: + r.ft("productidx").dropindex(True) +except redis.exceptions.ResponseError: + pass + +try: + r.ft("geomidx").dropindex(True) +except redis.exceptions.ResponseError: + pass + +r.delete("product:46885", "product:46886", "shape:1", "shape:2", "shape:3", "shape:4") +# REMOVE_END + +# STEP_START create_geo_idx +geo_schema = ( + GeoField("$.location", as_name="location") +) + +geo_index_create_result = r.ft("productidx").create_index( + geo_schema, + definition=IndexDefinition( + prefix=["product:"], index_type=IndexType.JSON + ) +) +print(geo_index_create_result) # >>> True +# REMOVE_START +assert geo_index_create_result +# REMOVE_END + +prd46885 = { + "description": "Navy Blue Slippers", + "price": 45.99, + "city": "Denver", + "location": "-104.991531, 39.742043" +} + +json_add_result_1 = r.json().set("product:46885", Path.root_path(), prd46885) +print(json_add_result_1) # >>> True + +prd46886 = { + "description": "Bright Green Socks", + "price": 25.50, + "city": "Fort Collins", + "location": "-105.0618814,40.5150098" +} + +json_add_result_2 = r.json().set("product:46886", Path.root_path(), prd46886) +print(json_add_result_2) # >>> True +# REMOVE_START +assert json_add_result_1 +assert json_add_result_2 +# REMOVE_END + +geo_result = r.ft("productidx").search( + "@location:[-104.800644 38.846127 100 mi]" +) +print(geo_result) +# >>> Result{1 total, docs: [Document {'id': 'product:46885'... +# STEP_END +# REMOVE_START +assert len(geo_result.docs) == 1 +assert geo_result.docs[0]["id"] == "product:46885" +# REMOVE_END + +# STEP_START create_gshape_idx +geom_schema = ( + TextField("$.name", as_name="name"), + GeoShapeField( + "$.geom", as_name="geom", coord_system=GeoShapeField.FLAT + ) +) + +geom_index_create_result = r.ft("geomidx").create_index( + geom_schema, + definition=IndexDefinition( + prefix=["shape:"], index_type=IndexType.JSON + ) +) +print(geom_index_create_result) # True +# REMOVE_START +assert geom_index_create_result +# REMOVE_END + +shape1 = { + "name": "Green Square", + "geom": "POLYGON ((1 1, 1 3, 3 3, 3 1, 1 1))" +} + +gm_json_res_1 = r.json().set("shape:1", Path.root_path(), shape1) +print(gm_json_res_1) # >>> True + +shape2 = { + "name": "Red Rectangle", + "geom": "POLYGON ((2 2.5, 2 3.5, 3.5 3.5, 3.5 2.5, 2 2.5))" +} + +gm_json_res_2 = r.json().set("shape:2", Path.root_path(), shape2) +print(gm_json_res_2) # >>> True + +shape3 = { + "name": "Blue Triangle", + "geom": "POLYGON ((3.5 1, 3.75 2, 4 1, 3.5 1))" +} + +gm_json_res_3 = r.json().set("shape:3", Path.root_path(), shape3) +print(gm_json_res_3) # >>> True + +shape4 = { + "name": "Purple Point", + "geom": "POINT (2 2)" +} + +gm_json_res_4 = r.json().set("shape:4", Path.root_path(), shape4) +print(gm_json_res_4) # >>> True +# REMOVE_START +assert gm_json_res_1 +assert gm_json_res_2 +assert gm_json_res_3 +assert gm_json_res_4 +# REMOVE_END + +geom_result = r.ft("geomidx").search( + Query( + "(-@name:(Green Square) @geom:[WITHIN $qshape])" + ).dialect(4).paging(0, 1), + query_params={ + "qshape": "POLYGON ((1 1, 1 3, 3 3, 3 1, 1 1))" + } +) +print(geom_result) +# >>> Result{1 total, docs: [Document {'id': 'shape:4'... +# STEP_END +# REMOVE_START +assert len(geom_result.docs) == 1 +assert geom_result.docs[0]["id"] == "shape:4" +# REMOVE_END From 38e35945ba9e1ec2518e32d328fbd8faf32da3fa Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Thu, 9 Jul 2026 13:39:53 +0100 Subject: [PATCH 15/28] DOC-6823 disable Try-it on document-database (payload over redis.io/cli URL limit) The external "Try it" button passes commands as base64 in the redis.io/cli URL, which caps at roughly 8192 bytes. The document-database create+load set (FT.CREATE + ten long JSON.SET descriptions) base64-encodes to ~8.6 KB -- over the cap -- and the two term queries inherit that size through needs_prereq, so all three interactive blocks are now try_it="false". Note the raw CLI text is only ~6 KB: the limit bites at the base64/URL layer, not the typed-command layer. The on-page terminal (one shared session, run top-to-bottom) still executes them, and the prereq/needs_prereq wiring is kept so the button can be re-enabled if the limit is ever raised. A repo-wide audit of all 559 interactive clients-example blocks (accounting for the needs_prereq prepend) found this is the only page over the limit. Learned: the Try-it button's size limit is on the base64 URL payload (~8192 B), not the raw CLI text -- doc-database's raw commands are ~6 KB but base64 to ~8.6 KB, and needs_prereq blocks inherit their prereq's payload size Constraint: keep try_it="false" on the document-database create_index + term-query blocks while the create+load payload base64s to over ~8192 bytes Directive: the prereq/needs_prereq wiring on this page is kept on purpose despite try_it="false" -- re-enable the button by removing try_it="false" only if the redis.io/cli payload limit is raised Ticket: DOC-6823 Co-Authored-By: Claude Opus 4.8 (1M context) --- content/develop/get-started/document-database.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/content/develop/get-started/document-database.md b/content/develop/get-started/document-database.md index 92c66edb90..fdc76f3953 100644 --- a/content/develop/get-started/document-database.md +++ b/content/develop/get-started/document-database.md @@ -73,7 +73,12 @@ The following example uses an [FT.CREATE]({{< relref "commands/ft.create" >}}) c Any pre-existing JSON documents with a key prefix `bicycle:` are automatically added to the index. Additionally, any JSON documents with that prefix created or modified after index creation are added or re-added to the index. -{{< clients-example set="search_quickstart" step="create_index" description="Foundational: Create an index on JSON documents with FT.CREATE, then add the documents with JSON.SET" difficulty="beginner" max_lines="2" prereq="true" >}} + +{{< clients-example set="search_quickstart" step="create_index" description="Foundational: Create an index on JSON documents with FT.CREATE, then add the documents with JSON.SET" difficulty="beginner" max_lines="2" prereq="true" try_it="false" >}} > FT.CREATE idx:bicycle ON JSON PREFIX 1 bicycle: SCORE 1.0 SCHEMA $.brand AS brand TEXT WEIGHT 1.0 $.model AS model TEXT WEIGHT 1.0 $.description AS description TEXT WEIGHT 1.0 $.price AS price NUMERIC $.condition AS condition TAG SEPARATOR , OK > JSON.SET "bicycle:0" "." "{\"brand\": \"Velorim\", \"model\": \"Jigger\", \"price\": 270, \"description\": \"Small and powerful, the Jigger is the best ride for the smallest of tikes! This is the tiniest kids\\u2019 pedal bike on the market available without a coaster brake, the Jigger is the vehicle of choice for the rare tenacious little rider raring to go.\", \"condition\": \"new\"}" @@ -143,7 +148,7 @@ You can retrieve all indexed documents using the [FT.SEARCH]({{< relref "command The following command shows a simple single-term query for finding all bicycles with a specific model: -{{< clients-example set="search_quickstart" step="query_single_term" description="Foundational: Perform a single-term full-text query using FT.SEARCH to find documents matching a specific field value" difficulty="beginner" needs_prereq="true" >}} +{{< clients-example set="search_quickstart" step="query_single_term" description="Foundational: Perform a single-term full-text query using FT.SEARCH to find documents matching a specific field value" difficulty="beginner" needs_prereq="true" try_it="false" >}} > FT.SEARCH "idx:bicycle" "@model:Jigger" LIMIT 0 10 1) (integer) 1 2) "bicycle:0" @@ -155,7 +160,7 @@ The following command shows a simple single-term query for finding all bicycles Below is a command to perform an exact match query that finds all bicycles with the brand name `Noka Bikes`. You must use double quotes around the search term when constructing an exact match query on a text field. -{{< clients-example set="search_quickstart" step="query_exact_matching" description="Foundational: Perform an exact match query using FT.SEARCH with double quotes to find documents with precise field values" difficulty="beginner" needs_prereq="true" >}} +{{< clients-example set="search_quickstart" step="query_exact_matching" description="Foundational: Perform an exact match query using FT.SEARCH with double quotes to find documents with precise field values" difficulty="beginner" needs_prereq="true" try_it="false" >}} > FT.SEARCH "idx:bicycle" "@brand:\"Noka Bikes\"" LIMIT 0 10 1) (integer) 1 2) "bicycle:4" From 8f8402ffac27bdadf43309afc8a485ae23acdacb Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Thu, 9 Jul 2026 13:56:44 +0100 Subject: [PATCH 16/28] =?UTF-8?q?DOC-6823=20add=20build/check=5Ftryit=5Fpa?= =?UTF-8?q?yloads.py=20=E2=80=94=20audit=20Try-it=20button=20payload=20siz?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scans every interactive clients-example block and flags any whose external "Try it" button payload (base64 of the JSON command array, including the needs_prereq prepend) exceeds the redis.io/cli ~8192-byte URL limit. Exits 1 if any are over, so it can gate CI. Found and fixed document-database as the sole offender in this PR; this makes the check repeatable. Co-Authored-By: Claude Opus 4.8 (1M context) --- build/check_tryit_payloads.py | 161 ++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 build/check_tryit_payloads.py diff --git a/build/check_tryit_payloads.py b/build/check_tryit_payloads.py new file mode 100644 index 0000000000..d406034749 --- /dev/null +++ b/build/check_tryit_payloads.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +""" +Check interactive "Try it" payload sizes across the docs. + +The external "Try it" button on a `clients-example` block opens +`redis.io/cli?commands=&autorun=true`, where is the URL-safe +base64 of a JSON array of the block's CLI commands. That URL payload is capped +at roughly 8192 bytes. Two subtleties this checker accounts for: + + * The cap is on the **base64 payload**, not the raw typed command text + (base64 inflates by ~33%, so ~6 KB of commands can exceed the cap). + * A block with `needs_prereq="true"` has its set's `prereq="true"` block + prepended by the button, so it inherits the prereq's payload size. + +A block is only subject to the limit if its external button is actually shown: +`try_it != "false"` and `runnable != "false"`. Blocks over the limit should get +`try_it="false"` (the on-page terminal still runs them — it does not go through +the URL). See the DOC-6823 work on content/develop/get-started/document-database.md. + +Usage: + python build/check_tryit_payloads.py [--content-dir content] [--limit 8192] + [--warn-frac 0.85] [--quiet] + +Exit status: 1 if any interactive block's payload exceeds the limit, else 0 +(so it can gate CI). Stdlib only; run from the repo root. +""" + +import argparse +import base64 +import glob +import json +import os +import re +import sys + +# Match a shortcode attribute value. The negative lookbehind on a word char +# stops `prereq="..."` from also matching inside `needs_prereq="..."`. +def _attr(tag, name): + m = re.search(r'(?\}\}', re.S) +_CLOSE = '{{< /clients-example' + + +def _parse_blocks(text): + """Yield (open_tag, body_between_tags) for each clients-example block.""" + parts = re.split(r'(' + _OPEN_TAG.pattern + r')', text, flags=re.S) + i = 1 + while i < len(parts): + tag = parts[i] + body = parts[i + 1] if i + 1 < len(parts) else "" + end = body.find(_CLOSE) + if end >= 0: + body = body[:end] + yield tag, body + i += 2 + + +def _cli_cmds(body): + """Extract the redis-cli command lines (the `> ` / `redis> ` prefixed lines).""" + cmds = [] + for line in body.split("\n"): + s = line.strip() + if s.startswith("redis> "): + cmds.append(s[len("redis> "):]) + elif s.startswith("> "): + cmds.append(s[2:]) + return cmds + + +def _payload_b64_len(cmds): + """Bytes of the URL-safe base64 payload the button would send (no padding).""" + js = json.dumps(cmds) + b64 = base64.b64encode(js.encode()).decode().rstrip("=") + return len(b64) + + +def scan(content_dir): + """Return (blocks, set_prereq) for all clients-example blocks under content_dir.""" + blocks = [] + set_prereq = {} # (file, set) -> prereq command list + for f in sorted(glob.glob(os.path.join(content_dir, "**", "*.md"), recursive=True)): + try: + text = open(f, encoding="utf-8").read() + except (OSError, UnicodeDecodeError): + continue + if "clients-example" not in text: + continue + for tag, body in _parse_blocks(text): + b = { + "file": f, + "set": _attr(tag, "set"), + "step": _attr(tag, "step"), + "prereq": _attr(tag, "prereq") == "true", + "needs_prereq": _attr(tag, "needs_prereq") == "true", + "runnable": _attr(tag, "runnable"), + "try_it": _attr(tag, "try_it"), + "cmds": _cli_cmds(body), + } + blocks.append(b) + if b["prereq"]: + set_prereq[(b["file"], b["set"])] = b["cmds"] + return blocks, set_prereq + + +def payload_for(b, set_prereq): + """The full command list the button would send for block b.""" + cmds = [] + if b["needs_prereq"]: + cmds += set_prereq.get((b["file"], b["set"]), []) + cmds += b["cmds"] + return cmds + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--content-dir", default="content") + ap.add_argument("--limit", type=int, default=8192, + help="max base64 payload bytes (default 8192)") + ap.add_argument("--warn-frac", type=float, default=0.85, + help="warn when payload exceeds this fraction of the limit") + ap.add_argument("--quiet", action="store_true", + help="only print offenders, not the summary") + args = ap.parse_args() + + blocks, set_prereq = scan(args.content_dir) + + over, near = [], [] + for b in blocks: + # Only blocks whose external button is actually shown are subject to the limit. + if b["try_it"] == "false" or b["runnable"] == "false" or not b["cmds"]: + continue + size = _payload_b64_len(payload_for(b, set_prereq)) + if size > args.limit: + over.append((size, b)) + elif size > args.limit * args.warn_frac: + near.append((size, b)) + + for size, b in sorted(near, key=lambda x: x[0], reverse=True): + print(f"WARN {size:6d} B ({100*size/args.limit:.0f}% of {args.limit}) " + f"{b['set']}/{b['step']} ({b['file']})") + for size, b in sorted(over, key=lambda x: x[0], reverse=True): + print(f"OVER {size:6d} B (> {args.limit}) " + f"{b['set']}/{b['step']} ({b['file']}) " + f"-- set try_it=\"false\"") + + if not args.quiet: + interactive = sum(1 for b in blocks + if b["try_it"] != "false" and b["runnable"] != "false" and b["cmds"]) + print(f"\nScanned {len(blocks)} blocks ({interactive} interactive) under " + f"{args.content_dir}/. Limit={args.limit} B (base64 URL payload). " + f"{len(over)} over, {len(near)} near.") + + return 1 if over else 0 + + +if __name__ == "__main__": + sys.exit(main()) From c58916e90f2de379bc7415f5f5419af2bfc0be77 Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Thu, 9 Jul 2026 14:11:31 +0100 Subject: [PATCH 17/28] DOC-6823 trim document-database descriptions under the Try-it limit; re-enable button Trims the two most verbose, un-queried bicycle descriptions (bicycle:6 WattBike, bicycle:7 Secto) so the create+load Try-it payload drops from ~8.6 KB base64 to ~7.1 KB (87% of the ~8192 limit). The three interactive blocks (the create_index prereq and the two term queries) therefore get try_it re-enabled -- the explicit try_it="false" is removed. Only descriptions that NO query touches were trimmed. The client override files carry hidden query steps (not shown on the page) that search "basic" (bicycle:5), "analytics" (bicycle:3), and "mountain" (bicycle:2/8/9, asserted total==3 with a BM25 equal-score tie between bicycle:2 and bicycle:9). Those docs are left intact; bicycle:6/7 are neither queried nor "mountain" docs, so the corpus-sensitive BM25 assertions still hold. Verified by running the redis-py override (the query superset) against Redis 8.8: EXIT 0, mountain total==3, bicycle:2==bicycle:9 tie, all fuzzy/scorer/aggregation asserts pass. The same trimmed text is applied across the markdown (JSON.SET command + wildcard static output) and all five client overrides. build/check_tryit_payloads.py now reports 0 over. Learned: trimming a doctest description only breaks something if that exact text is queried; the hidden search_quickstart steps depend on "basic"/"analytics"/"mountain" plus a BM25 total==3 and an equal-score tie among the mountain docs, so trimming the un-queried outliers is safe (confirmed on Redis 8.8) Constraint: keep "basic" in bicycle:5, "analytics" in bicycle:3, and "mountain" in bicycle:2/8/9 (the total==3 + equal-score BM25 assertions depend on them) when editing these descriptions Directive: the same description text must stay identical across the markdown JSON.SET command, the wildcard static output, and all five client overrides -- edit all six together Ticket: DOC-6823 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../develop/get-started/document-database.md | 19 ++++++--------- .../go-redis/search_quickstart_test.go | 24 ++----------------- .../jedis/SearchQuickstartExample.java | 24 ++----------------- .../node-redis/search-quickstart.js | 4 ++-- .../nredisstack/SearchQuickstartExample.cs | 24 ++----------------- .../redis-py/search_quickstart.py | 24 ++----------------- 6 files changed, 17 insertions(+), 102 deletions(-) diff --git a/content/develop/get-started/document-database.md b/content/develop/get-started/document-database.md index fdc76f3953..41c1d6ac79 100644 --- a/content/develop/get-started/document-database.md +++ b/content/develop/get-started/document-database.md @@ -73,12 +73,7 @@ The following example uses an [FT.CREATE]({{< relref "commands/ft.create" >}}) c Any pre-existing JSON documents with a key prefix `bicycle:` are automatically added to the index. Additionally, any JSON documents with that prefix created or modified after index creation are added or re-added to the index. - -{{< clients-example set="search_quickstart" step="create_index" description="Foundational: Create an index on JSON documents with FT.CREATE, then add the documents with JSON.SET" difficulty="beginner" max_lines="2" prereq="true" try_it="false" >}} +{{< clients-example set="search_quickstart" step="create_index" description="Foundational: Create an index on JSON documents with FT.CREATE, then add the documents with JSON.SET" difficulty="beginner" max_lines="2" prereq="true" >}} > FT.CREATE idx:bicycle ON JSON PREFIX 1 bicycle: SCORE 1.0 SCHEMA $.brand AS brand TEXT WEIGHT 1.0 $.model AS model TEXT WEIGHT 1.0 $.description AS description TEXT WEIGHT 1.0 $.price AS price NUMERIC $.condition AS condition TAG SEPARATOR , OK > JSON.SET "bicycle:0" "." "{\"brand\": \"Velorim\", \"model\": \"Jigger\", \"price\": 270, \"description\": \"Small and powerful, the Jigger is the best ride for the smallest of tikes! This is the tiniest kids\\u2019 pedal bike on the market available without a coaster brake, the Jigger is the vehicle of choice for the rare tenacious little rider raring to go.\", \"condition\": \"new\"}" @@ -93,9 +88,9 @@ OK OK > JSON.SET "bicycle:5" "." "{\"brand\": \"Breakout\", \"model\": \"XBN 2.1 Alloy\", \"price\": 810, \"description\": \"The XBN 2.1 Alloy is our entry-level road bike \\u2013 but that\\u2019s not to say that it\\u2019s a basic machine. With an internal weld aluminium frame, a full carbon fork, and the slick-shifting Claris gears from Shimano\\u2019s, this is a bike which doesn\\u2019t break the bank and delivers craved performance.\", \"condition\": \"new\"}" OK -> JSON.SET "bicycle:6" "." "{\"brand\": \"ScramBikes\", \"model\": \"WattBike\", \"price\": 2300, \"description\": \"The WattBike is the best e-bike for people who still feel young at heart. It has a Bafang 1000W mid-drive system and a 48V 17.5AH Samsung Lithium-Ion battery, allowing you to ride for more than 60 miles on one charge. It\\u2019s great for tackling hilly terrain or if you just fancy a more leisurely ride. With three working modes, you can choose between E-bike, assisted bicycle, and normal bike modes.\", \"condition\": \"new\"}" +> JSON.SET "bicycle:6" "." "{\"brand\": \"ScramBikes\", \"model\": \"WattBike\", \"price\": 2300, \"description\": \"An e-bike with a 1000W mid-drive and a 48V battery, offering over 60 miles per charge and three riding modes.\", \"condition\": \"new\"}" OK -> JSON.SET "bicycle:7" "." "{\"brand\": \"Peaknetic\", \"model\": \"Secto\", \"price\": 430, \"description\": \"If you struggle with stiff fingers or a kinked neck or back after a few minutes on the road, this lightweight, aluminum bike alleviates those issues and allows you to enjoy the ride. From the ergonomic grips to the lumbar-supporting seat position, the Roll Low-Entry offers incredible comfort. The rear-inclined seat tube facilitates stability by allowing you to put a foot on the ground to balance at a stop, and the low step-over frame makes it accessible for all ability and mobility levels. The saddle is very soft, with a wide back to support your hip joints and a cutout in the center to redistribute that pressure. Rim brakes deliver satisfactory braking control, and the wide tires provide a smooth, stable ride on paved roads and gravel. Rack and fender mounts facilitate setting up the Roll Low-Entry as your preferred commuter, and the BMX-like handlebar offers space for mounting a flashlight, bell, or phone holder.\", \"condition\": \"new\"}" +> JSON.SET "bicycle:7" "." "{\"brand\": \"Peaknetic\", \"model\": \"Secto\", \"price\": 430, \"description\": \"A lightweight aluminum commuter bike with ergonomic grips, a lumbar-supporting seat, and a low step-over frame for comfort and easy mounting.\", \"condition\": \"new\"}" OK > JSON.SET "bicycle:8" "." "{\"brand\": \"nHill\", \"model\": \"Summit\", \"price\": 1200, \"description\": \"This budget mountain bike from nHill performs well both on bike paths and on the trail. The fork with 100mm of travel absorbs rough terrain. Fat Kenda Booster tires give you grip in corners and on wet trails. The Shimano Tourney drivetrain offered enough gears for finding a comfortable pace to ride uphill, and the Tektro hydraulic disc brakes break smoothly. Whether you want an affordable bike that you can take to work, but also take trail in mountains on the weekends or you\\u2019re just after a stable, comfortable ride for the bike path, the Summit gives a good value for money.\", \"condition\": \"new\"}" OK @@ -129,10 +124,10 @@ You can retrieve all indexed documents using the [FT.SEARCH]({{< relref "command 2) "{\"brand\":\"Velorim\",\"model\":\"Jigger\",\"price\":270,\"description\":\"Small and powerful, the Jigger is the best ride for the smallest of tikes! This is the tiniest kids\xe2\x80\x99 pedal bike on the market available without a coaster brake, the Jigger is the vehicle of choice for the rare tenacious little rider raring to go.\",\"condition\":\"new\"}" 12) "bicycle:6" 13) 1) "$" - 2) "{\"brand\":\"ScramBikes\",\"model\":\"WattBike\",\"price\":2300,\"description\":\"The WattBike is the best e-bike for people who still feel young at heart. It has a Bafang 1000W mid-drive system and a 48V 17.5AH Samsung Lithium-Ion battery, allowing you to ride for more than 60 miles on one charge. It\xe2\x80\x99s great for tackling hilly terrain or if you just fancy a more leisurely ride. With three working modes, you can choose between E-bike, assisted bicycle, and normal bike modes.\",\"condition\":\"new\"}" + 2) "{\"brand\":\"ScramBikes\",\"model\":\"WattBike\",\"price\":2300,\"description\":\"An e-bike with a 1000W mid-drive and a 48V battery, offering over 60 miles per charge and three riding modes.\",\"condition\":\"new\"}" 14) "bicycle:7" 15) 1) "$" - 2) "{\"brand\":\"Peaknetic\",\"model\":\"Secto\",\"price\":430,\"description\":\"If you struggle with stiff fingers or a kinked neck or back after a few minutes on the road, this lightweight, aluminum bike alleviates those issues and allows you to enjoy the ride. From the ergonomic grips to the lumbar-supporting seat position, the Roll Low-Entry offers incredible comfort. The rear-inclined seat tube facilitates stability by allowing you to put a foot on the ground to balance at a stop, and the low step-over frame makes it accessible for all ability and mobility levels. The saddle is very soft, with a wide back to support your hip joints and a cutout in the center to redistribute that pressure. Rim brakes deliver satisfactory braking control, and the wide tires provide a smooth, stable ride on paved roads and gravel. Rack and fender mounts facilitate setting up the Roll Low-Entry as your preferred commuter, and the BMX-like handlebar offers space for mounting a flashlight, bell, or phone holder.\",\"condition\":\"new\"}" + 2) "{\"brand\":\"Peaknetic\",\"model\":\"Secto\",\"price\":430,\"description\":\"A lightweight aluminum commuter bike with ergonomic grips, a lumbar-supporting seat, and a low step-over frame for comfort and easy mounting.\",\"condition\":\"new\"}" 16) "bicycle:9" 17) 1) "$" 2) "{\"model\":\"ThrillCycle\",\"brand\":\"BikeShind\",\"price\":815,\"description\":\"An artsy, retro-inspired bicycle that\xe2\x80\x99s as functional as it is pretty: The ThrillCycle steel frame offers a smooth ride. A 9-speed drivetrain has enough gears for coasting in the city, but we wouldn\xe2\x80\x99t suggest taking it to the mountains. Fenders protect you from mud, and a rear basket lets you transport groceries, flowers and books. The ThrillCycle comes with a limited lifetime warranty, so this little guy will last you long past graduation.\",\"condition\":\"refurbished\"}" @@ -148,7 +143,7 @@ You can retrieve all indexed documents using the [FT.SEARCH]({{< relref "command The following command shows a simple single-term query for finding all bicycles with a specific model: -{{< clients-example set="search_quickstart" step="query_single_term" description="Foundational: Perform a single-term full-text query using FT.SEARCH to find documents matching a specific field value" difficulty="beginner" needs_prereq="true" try_it="false" >}} +{{< clients-example set="search_quickstart" step="query_single_term" description="Foundational: Perform a single-term full-text query using FT.SEARCH to find documents matching a specific field value" difficulty="beginner" needs_prereq="true" >}} > FT.SEARCH "idx:bicycle" "@model:Jigger" LIMIT 0 10 1) (integer) 1 2) "bicycle:0" @@ -160,7 +155,7 @@ The following command shows a simple single-term query for finding all bicycles Below is a command to perform an exact match query that finds all bicycles with the brand name `Noka Bikes`. You must use double quotes around the search term when constructing an exact match query on a text field. -{{< clients-example set="search_quickstart" step="query_exact_matching" description="Foundational: Perform an exact match query using FT.SEARCH with double quotes to find documents with precise field values" difficulty="beginner" needs_prereq="true" try_it="false" >}} +{{< clients-example set="search_quickstart" step="query_exact_matching" description="Foundational: Perform an exact match query using FT.SEARCH with double quotes to find documents with precise field values" difficulty="beginner" needs_prereq="true" >}} > FT.SEARCH "idx:bicycle" "@brand:\"Noka Bikes\"" LIMIT 0 10 1) (integer) 1 2) "bicycle:4" diff --git a/local_examples/search_quickstart/go-redis/search_quickstart_test.go b/local_examples/search_quickstart/go-redis/search_quickstart_test.go index a731f452a6..18a6228578 100644 --- a/local_examples/search_quickstart/go-redis/search_quickstart_test.go +++ b/local_examples/search_quickstart/go-redis/search_quickstart_test.go @@ -90,34 +90,14 @@ var bicycles = []interface{}{ "brand": "ScramBikes", "model": "WattBike", "price": 2300, - "description": "The WattBike is the best e-bike for people who still feel young" + - " at heart. It has a Bafang 1000W mid-drive system and a 48V" + - " 17.5AH Samsung Lithium-Ion battery, allowing you to ride for" + - " more than 60 miles on one charge. It’s great for tackling hilly" + - " terrain or if you just fancy a more leisurely ride. With three" + - " working modes, you can choose between E-bike, assisted bicycle," + - " and normal bike modes.", + "description": "An e-bike with a 1000W mid-drive and a 48V battery, offering over 60 miles per charge and three riding modes.", "condition": "new", }, map[string]interface{}{ "brand": "Peaknetic", "model": "Secto", "price": 430, - "description": "If you struggle with stiff fingers or a kinked neck or back after" + - " a few minutes on the road, this lightweight, aluminum bike" + - " alleviates those issues and allows you to enjoy the ride. From" + - " the ergonomic grips to the lumbar-supporting seat position, the" + - " Roll Low-Entry offers incredible comfort. The rear-inclined seat" + - " tube facilitates stability by allowing you to put a foot on the" + - " ground to balance at a stop, and the low step-over frame makes it" + - " accessible for all ability and mobility levels. The saddle is" + - " very soft, with a wide back to support your hip joints and a" + - " cutout in the center to redistribute that pressure. Rim brakes" + - " deliver satisfactory braking control, and the wide tires provide" + - " a smooth, stable ride on paved roads and gravel. Rack and fender" + - " mounts facilitate setting up the Roll Low-Entry as your preferred" + - " commuter, and the BMX-like handlebar offers space for mounting a" + - " flashlight, bell, or phone holder.", + "description": "A lightweight aluminum commuter bike with ergonomic grips, a lumbar-supporting seat, and a low step-over frame for comfort and easy mounting.", "condition": "new", }, map[string]interface{}{ diff --git a/local_examples/search_quickstart/jedis/SearchQuickstartExample.java b/local_examples/search_quickstart/jedis/SearchQuickstartExample.java index 3bd8d8c12e..ede8b1e46b 100644 --- a/local_examples/search_quickstart/jedis/SearchQuickstartExample.java +++ b/local_examples/search_quickstart/jedis/SearchQuickstartExample.java @@ -142,34 +142,14 @@ public void run() { "WattBike", new BigDecimal(2300), "new", - "The WattBike is the best e-bike for people who still feel young" + - " at heart. It has a Bafang 1000W mid-drive system and a 48V" + - " 17.5AH Samsung Lithium-Ion battery, allowing you to ride for" + - " more than 60 miles on one charge. It’s great for tackling hilly" + - " terrain or if you just fancy a more leisurely ride. With three" + - " working modes, you can choose between E-bike, assisted bicycle," + - " and normal bike modes." + "An e-bike with a 1000W mid-drive and a 48V battery, offering over 60 miles per charge and three riding modes." ), new Bicycle( "Peaknetic", "Secto", new BigDecimal(430), "new", - "If you struggle with stiff fingers or a kinked neck or back after" + - " a few minutes on the road, this lightweight, aluminum bike" + - " alleviates those issues and allows you to enjoy the ride. From" + - " the ergonomic grips to the lumbar-supporting seat position, the" + - " Roll Low-Entry offers incredible comfort. The rear-inclined seat" + - " tube facilitates stability by allowing you to put a foot on the" + - " ground to balance at a stop, and the low step-over frame makes it" + - " accessible for all ability and mobility levels. The saddle is" + - " very soft, with a wide back to support your hip joints and a" + - " cutout in the center to redistribute that pressure. Rim brakes" + - " deliver satisfactory braking control, and the wide tires provide" + - " a smooth, stable ride on paved roads and gravel. Rack and fender" + - " mounts facilitate setting up the Roll Low-Entry as your preferred" + - " commuter, and the BMX-like handlebar offers space for mounting a" + - " flashlight, bell, or phone holder." + "A lightweight aluminum commuter bike with ergonomic grips, a lumbar-supporting seat, and a low step-over frame for comfort and easy mounting." ), new Bicycle( "nHill", diff --git a/local_examples/search_quickstart/node-redis/search-quickstart.js b/local_examples/search_quickstart/node-redis/search-quickstart.js index 44d48f39fa..64c28c30c9 100644 --- a/local_examples/search_quickstart/node-redis/search-quickstart.js +++ b/local_examples/search_quickstart/node-redis/search-quickstart.js @@ -67,14 +67,14 @@ const bicycles = [ brand: 'ScramBikes', model: 'WattBike', price: 2300, - description: 'The WattBike is the best e-bike for people who still feel young at heart. It has a Bafang 1000W mid-drive system and a 48V 17.5AH Samsung Lithium-Ion battery, allowing you to ride for more than 60 miles on one charge. It\u2019s great for tackling hilly terrain or if you just fancy a more leisurely ride. With three working modes, you can choose between E-bike, assisted bicycle, and normal bike modes.', + description: 'An e-bike with a 1000W mid-drive and a 48V battery, offering over 60 miles per charge and three riding modes.', condition: 'new' }, { brand: 'Peaknetic', model: 'Secto', price: 430, - description: 'If you struggle with stiff fingers or a kinked neck or back after a few minutes on the road, this lightweight, aluminum bike alleviates those issues and allows you to enjoy the ride. From the ergonomic grips to the lumbar-supporting seat position, the Roll Low-Entry offers incredible comfort. The rear-inclined seat tube facilitates stability by allowing you to put a foot on the ground to balance at a stop, and the low step-over frame makes it accessible for all ability and mobility levels. The saddle is very soft, with a wide back to support your hip joints and a cutout in the center to redistribute that pressure. Rim brakes deliver satisfactory braking control, and the wide tires provide a smooth, stable ride on paved roads and gravel. Rack and fender mounts facilitate setting up the Roll Low-Entry as your preferred commuter, and the BMX-like handlebar offers space for mounting a flashlight, bell, or phone holder.', + description: 'A lightweight aluminum commuter bike with ergonomic grips, a lumbar-supporting seat, and a low step-over frame for comfort and easy mounting.', condition: 'new' }, { diff --git a/local_examples/search_quickstart/nredisstack/SearchQuickstartExample.cs b/local_examples/search_quickstart/nredisstack/SearchQuickstartExample.cs index dfbab288f1..94cc75763b 100644 --- a/local_examples/search_quickstart/nredisstack/SearchQuickstartExample.cs +++ b/local_examples/search_quickstart/nredisstack/SearchQuickstartExample.cs @@ -138,13 +138,7 @@ public void Run() Brand = "ScramBikes", Model = "WattBike", Price = 2300M, - Description = "The WattBike is the best e-bike for people who still feel young" + - " at heart. It has a Bafang 1000W mid-drive system and a 48V" + - " 17.5AH Samsung Lithium-Ion battery, allowing you to ride for" + - " more than 60 miles on one charge. It’s great for tackling hilly" + - " terrain or if you just fancy a more leisurely ride. With three" + - " working modes, you can choose between E-bike, assisted bicycle," + - " and normal bike modes.", + Description = "An e-bike with a 1000W mid-drive and a 48V battery, offering over 60 miles per charge and three riding modes.", Condition = "new", }, new @@ -152,21 +146,7 @@ public void Run() Brand = "Peaknetic", Model = "Secto", Price = 430M, - Description = "If you struggle with stiff fingers or a kinked neck or back after" + - " a few minutes on the road, this lightweight, aluminum bike" + - " alleviates those issues and allows you to enjoy the ride. From" + - " the ergonomic grips to the lumbar-supporting seat position, the" + - " Roll Low-Entry offers incredible comfort. The rear-inclined seat" + - " tube facilitates stability by allowing you to put a foot on the" + - " ground to balance at a stop, and the low step-over frame makes it" + - " accessible for all ability and mobility levels. The saddle is" + - " very soft, with a wide back to support your hip joints and a" + - " cutout in the center to redistribute that pressure. Rim brakes" + - " deliver satisfactory braking control, and the wide tires provide" + - " a smooth, stable ride on paved roads and gravel. Rack and fender" + - " mounts facilitate setting up the Roll Low-Entry as your preferred" + - " commuter, and the BMX-like handlebar offers space for mounting a" + - " flashlight, bell, or phone holder.", + Description = "A lightweight aluminum commuter bike with ergonomic grips, a lumbar-supporting seat, and a low step-over frame for comfort and easy mounting.", Condition = "new", }, new diff --git a/local_examples/search_quickstart/redis-py/search_quickstart.py b/local_examples/search_quickstart/redis-py/search_quickstart.py index 7201d9f560..dd1b3022d7 100644 --- a/local_examples/search_quickstart/redis-py/search_quickstart.py +++ b/local_examples/search_quickstart/redis-py/search_quickstart.py @@ -120,13 +120,7 @@ "model": "WattBike", "price": 2300, "description": ( - "The WattBike is the best e-bike for people who still feel young" - " at heart. It has a Bafang 1000W mid-drive system and a 48V" - " 17.5AH Samsung Lithium-Ion battery, allowing you to ride for" - " more than 60 miles on one charge. It’s great for tackling hilly" - " terrain or if you just fancy a more leisurely ride. With three" - " working modes, you can choose between E-bike, assisted bicycle," - " and normal bike modes." + "An e-bike with a 1000W mid-drive and a 48V battery, offering over 60 miles per charge and three riding modes." ), "condition": "new", }, @@ -135,21 +129,7 @@ "model": "Secto", "price": 430, "description": ( - "If you struggle with stiff fingers or a kinked neck or back after" - " a few minutes on the road, this lightweight, aluminum bike" - " alleviates those issues and allows you to enjoy the ride. From" - " the ergonomic grips to the lumbar-supporting seat position, the" - " Roll Low-Entry offers incredible comfort. The rear-inclined seat" - " tube facilitates stability by allowing you to put a foot on the" - " ground to balance at a stop, and the low step-over frame makes it" - " accessible for all ability and mobility levels. The saddle is" - " very soft, with a wide back to support your hip joints and a" - " cutout in the center to redistribute that pressure. Rim brakes" - " deliver satisfactory braking control, and the wide tires provide" - " a smooth, stable ride on paved roads and gravel. Rack and fender" - " mounts facilitate setting up the Roll Low-Entry as your preferred" - " commuter, and the BMX-like handlebar offers space for mounting a" - " flashlight, bell, or phone holder." + "A lightweight aluminum commuter bike with ergonomic grips, a lumbar-supporting seat, and a low step-over frame for comfort and easy mounting." ), "condition": "new", }, From f37804fd825e65895f0ac0a1b97e036e5890299d Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Thu, 9 Jul 2026 14:23:32 +0100 Subject: [PATCH 18/28] DOC-6823 correct Try-it limit to 4094 request-line; re-disable document-database button The real redis.io/cli cap is ~4094 bytes on the HTTP request line ("Request Line is too large (7163 > 4094)"), not 8192 on the payload as first assumed -- roughly half. The document-database create+load is ~7.2 KB base64 even after trimming two descriptions, so its three interactive blocks go back to try_it="false" (the on-page terminal still runs them). build/check_tryit_payloads.py now models the request line (base64 + ~40 B scaffolding) against a 4094 limit instead of 8192, and re-auditing found document-database is still the only page OVER; the json_tutorial blocks on data-types/json/path.md sit at 87-98% (near but under). Learned: the redis.io/cli "Try it" cap is ~4094 bytes on the HTTP request line (base64 payload + ~24-40 B of GET/query scaffolding), not 8192 on the payload -- a 10-JSON-document create+load can't fit without gutting the descriptions Constraint: keep document-database's create_index + term-query blocks try_it="false" until the create+load base64 payload is under ~4054 bytes Gaps: json_tutorial (data-types/json/path.md) has ~12 interactive blocks at 87-98% of the 4094 limit -- pre-existing, under the cap today, but one edit from breaking; not changed here Ticket: DOC-6823 Co-Authored-By: Claude Opus 4.8 (1M context) --- build/check_tryit_payloads.py | 36 +++++++++++-------- .../develop/get-started/document-database.md | 11 ++++-- 2 files changed, 30 insertions(+), 17 deletions(-) diff --git a/build/check_tryit_payloads.py b/build/check_tryit_payloads.py index d406034749..f02d6edb26 100644 --- a/build/check_tryit_payloads.py +++ b/build/check_tryit_payloads.py @@ -118,8 +118,13 @@ def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--content-dir", default="content") - ap.add_argument("--limit", type=int, default=8192, - help="max base64 payload bytes (default 8192)") + ap.add_argument("--limit", type=int, default=4094, + help="max HTTP request-line bytes for redis.io/cli " + "(observed server cap: 4094)") + ap.add_argument("--overhead", type=int, default=40, + help="bytes added to the base64 payload to form the request " + "line: 'GET /cli?commands=' + '&autorun=true HTTP/1.1' " + "(~40; observed ~24, so 40 is conservative)") ap.add_argument("--warn-frac", type=float, default=0.85, help="warn when payload exceeds this fraction of the limit") ap.add_argument("--quiet", action="store_true", @@ -133,25 +138,28 @@ def main(): # Only blocks whose external button is actually shown are subject to the limit. if b["try_it"] == "false" or b["runnable"] == "false" or not b["cmds"]: continue - size = _payload_b64_len(payload_for(b, set_prereq)) + b64 = _payload_b64_len(payload_for(b, set_prereq)) + size = b64 + args.overhead # estimated HTTP request-line length if size > args.limit: - over.append((size, b)) + over.append((size, b64, b)) elif size > args.limit * args.warn_frac: - near.append((size, b)) + near.append((size, b64, b)) - for size, b in sorted(near, key=lambda x: x[0], reverse=True): - print(f"WARN {size:6d} B ({100*size/args.limit:.0f}% of {args.limit}) " - f"{b['set']}/{b['step']} ({b['file']})") - for size, b in sorted(over, key=lambda x: x[0], reverse=True): - print(f"OVER {size:6d} B (> {args.limit}) " - f"{b['set']}/{b['step']} ({b['file']}) " - f"-- set try_it=\"false\"") + for size, b64, b in sorted(near, key=lambda x: x[0], reverse=True): + print(f"WARN req-line ~{size} B ({100*size/args.limit:.0f}% of " + f"{args.limit}, b64={b64}) {b['set']}/{b['step']} ({b['file']})") + for size, b64, b in sorted(over, key=lambda x: x[0], reverse=True): + print(f"OVER req-line ~{size} B (> {args.limit}, b64={b64}) " + f"{b['set']}/{b['step']} ({b['file']}) -- set try_it=\"false\" " + f"or trim to b64 <= {args.limit - args.overhead}") if not args.quiet: interactive = sum(1 for b in blocks - if b["try_it"] != "false" and b["runnable"] != "false" and b["cmds"]) + if b["try_it"] != "false" and b["runnable"] != "false" + and b["cmds"]) print(f"\nScanned {len(blocks)} blocks ({interactive} interactive) under " - f"{args.content_dir}/. Limit={args.limit} B (base64 URL payload). " + f"{args.content_dir}/. Request-line limit={args.limit} B " + f"(base64 payload + {args.overhead} B scaffolding). " f"{len(over)} over, {len(near)} near.") return 1 if over else 0 diff --git a/content/develop/get-started/document-database.md b/content/develop/get-started/document-database.md index 41c1d6ac79..9abdb219e1 100644 --- a/content/develop/get-started/document-database.md +++ b/content/develop/get-started/document-database.md @@ -73,7 +73,12 @@ The following example uses an [FT.CREATE]({{< relref "commands/ft.create" >}}) c Any pre-existing JSON documents with a key prefix `bicycle:` are automatically added to the index. Additionally, any JSON documents with that prefix created or modified after index creation are added or re-added to the index. -{{< clients-example set="search_quickstart" step="create_index" description="Foundational: Create an index on JSON documents with FT.CREATE, then add the documents with JSON.SET" difficulty="beginner" max_lines="2" prereq="true" >}} + +{{< clients-example set="search_quickstart" step="create_index" description="Foundational: Create an index on JSON documents with FT.CREATE, then add the documents with JSON.SET" difficulty="beginner" max_lines="2" prereq="true" try_it="false" >}} > FT.CREATE idx:bicycle ON JSON PREFIX 1 bicycle: SCORE 1.0 SCHEMA $.brand AS brand TEXT WEIGHT 1.0 $.model AS model TEXT WEIGHT 1.0 $.description AS description TEXT WEIGHT 1.0 $.price AS price NUMERIC $.condition AS condition TAG SEPARATOR , OK > JSON.SET "bicycle:0" "." "{\"brand\": \"Velorim\", \"model\": \"Jigger\", \"price\": 270, \"description\": \"Small and powerful, the Jigger is the best ride for the smallest of tikes! This is the tiniest kids\\u2019 pedal bike on the market available without a coaster brake, the Jigger is the vehicle of choice for the rare tenacious little rider raring to go.\", \"condition\": \"new\"}" @@ -143,7 +148,7 @@ You can retrieve all indexed documents using the [FT.SEARCH]({{< relref "command The following command shows a simple single-term query for finding all bicycles with a specific model: -{{< clients-example set="search_quickstart" step="query_single_term" description="Foundational: Perform a single-term full-text query using FT.SEARCH to find documents matching a specific field value" difficulty="beginner" needs_prereq="true" >}} +{{< clients-example set="search_quickstart" step="query_single_term" description="Foundational: Perform a single-term full-text query using FT.SEARCH to find documents matching a specific field value" difficulty="beginner" needs_prereq="true" try_it="false" >}} > FT.SEARCH "idx:bicycle" "@model:Jigger" LIMIT 0 10 1) (integer) 1 2) "bicycle:0" @@ -155,7 +160,7 @@ The following command shows a simple single-term query for finding all bicycles Below is a command to perform an exact match query that finds all bicycles with the brand name `Noka Bikes`. You must use double quotes around the search term when constructing an exact match query on a text field. -{{< clients-example set="search_quickstart" step="query_exact_matching" description="Foundational: Perform an exact match query using FT.SEARCH with double quotes to find documents with precise field values" difficulty="beginner" needs_prereq="true" >}} +{{< clients-example set="search_quickstart" step="query_exact_matching" description="Foundational: Perform an exact match query using FT.SEARCH with double quotes to find documents with precise field values" difficulty="beginner" needs_prereq="true" try_it="false" >}} > FT.SEARCH "idx:bicycle" "@brand:\"Noka Bikes\"" LIMIT 0 10 1) (integer) 1 2) "bicycle:4" From 3b59414b186d822ca356b551bcfba24862b7aee8 Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Thu, 9 Jul 2026 14:39:04 +0100 Subject: [PATCH 19/28] DOC-6823 fix pre-existing upstream defects Bugbot flagged in the new overrides Cursor Bugbot flagged 6 issues across the search_quickstart and geoindex client overrides added in this PR. All are pre-existing defects in the upstream client examples that we inherited by copying them into local_examples -- the STEP-marker merge and description trims did not introduce them; they surface now only because Bugbot reviews these files for the first time. Fixes: - node search_quickstart: "// REMOVE END" -> "// REMOVE_END" (the typo left the REMOVE region open, stripping everything after it). - C# search_quickstart: bicycle:0 (Jigger) Condition "used" -> "new" to match the page and every other client; the aggregation only asserts the group count (3), so no ripple -- just corrected the stale output comment (and jedis's identical stale used-5/new-4 comment) to new-5/used-4. - C# geoindex: GEOSHAPE query given an explicit .Dialect(2) -- the WITHIN operator is a syntax error on the server-default dialect 1 (verified on Redis 8.8). - jedis geoindex: geom index addPrefix("shape") -> "shape:" for consistency with the documented PREFIX and the other clients. - C# geoindex: debug prints after shape:2 / shape:4 fixed to gmJsonRes2 / gmJsonRes4. - jedis search_quickstart: wrapped the bare aggregation assertEquals in REMOVE_START/END, matching every other assert in the file. Learned: creating local_examples overrides by copying upstream client files pulls their latent defects into our repo and review scope; Bugbot then flags them as "new" though they predate the copy -- budget an upstream-quality pass when localizing example files Directive: these fixes should also land upstream (redis/node-redis, redis/NRedisStack, redis/jedis) since the overrides are otherwise faithful copies Ticket: DOC-6823 Co-Authored-By: Claude Opus 4.8 (1M context) --- local_examples/geoindex/jedis/GeoIndexExample.java | 2 +- local_examples/geoindex/nredisstack/GeoIndexExample.cs | 5 +++-- .../search_quickstart/jedis/SearchQuickstartExample.java | 6 ++++-- .../search_quickstart/node-redis/search-quickstart.js | 2 +- .../nredisstack/SearchQuickstartExample.cs | 6 +++--- 5 files changed, 12 insertions(+), 9 deletions(-) diff --git a/local_examples/geoindex/jedis/GeoIndexExample.java b/local_examples/geoindex/jedis/GeoIndexExample.java index 779f82dc26..1f6d06817d 100644 --- a/local_examples/geoindex/jedis/GeoIndexExample.java +++ b/local_examples/geoindex/jedis/GeoIndexExample.java @@ -105,7 +105,7 @@ public void run() { String geomIndexCreateResult = jedis.ftCreate("geomidx", FTCreateParams.createParams() .on(IndexDataType.JSON) - .addPrefix("shape"), + .addPrefix("shape:"), geomSchema ); System.out.println(geomIndexCreateResult); // >>> OK diff --git a/local_examples/geoindex/nredisstack/GeoIndexExample.cs b/local_examples/geoindex/nredisstack/GeoIndexExample.cs index 18c8940d55..58f9d2ffbc 100644 --- a/local_examples/geoindex/nredisstack/GeoIndexExample.cs +++ b/local_examples/geoindex/nredisstack/GeoIndexExample.cs @@ -145,7 +145,7 @@ public void run() }; bool gmJsonRes2 = db.JSON().Set("shape:2", "$", shape2); - Console.WriteLine(gmJsonRes1); // >>> True + Console.WriteLine(gmJsonRes2); // >>> True var shape3 = new { @@ -163,7 +163,7 @@ public void run() }; bool gmJsonRes4 = db.JSON().Set("shape:4", "$", shape4); - Console.WriteLine(gmJsonRes3); // >>> True + Console.WriteLine(gmJsonRes4); // >>> True // REMOVE_START Assert.True(gmJsonRes1); Assert.True(gmJsonRes2); @@ -178,6 +178,7 @@ public void run() new Query("(-@name:(Green Square) @geom:[WITHIN $qshape])") .AddParam("qshape", "POLYGON ((1 1, 1 3, 3 3, 3 1, 1 1))") .Limit(0, 1) + .Dialect(2) ); Console.WriteLine(geomQueryResult.Documents.Count); // >>> 1 diff --git a/local_examples/search_quickstart/jedis/SearchQuickstartExample.java b/local_examples/search_quickstart/jedis/SearchQuickstartExample.java index ede8b1e46b..ada8c1461a 100644 --- a/local_examples/search_quickstart/jedis/SearchQuickstartExample.java +++ b/local_examples/search_quickstart/jedis/SearchQuickstartExample.java @@ -250,9 +250,11 @@ public void run() { } // Prints: // refurbished - 1 - // used - 5 - // new - 4 + // used - 4 + // new - 5 + // REMOVE_START assertEquals(3, ar.getTotalResults(), "Validate aggregation results"); + // REMOVE_END // STEP_END jedis.close(); diff --git a/local_examples/search_quickstart/node-redis/search-quickstart.js b/local_examples/search_quickstart/node-redis/search-quickstart.js index 64c28c30c9..8c5b4553dd 100644 --- a/local_examples/search_quickstart/node-redis/search-quickstart.js +++ b/local_examples/search_quickstart/node-redis/search-quickstart.js @@ -224,6 +224,6 @@ console.log(JSON.stringify(result, null, 2)); // REMOVE_START assert.equal(result.documents[0].id, 'bicycle:4'); -// REMOVE END +// REMOVE_END await client.close(); diff --git a/local_examples/search_quickstart/nredisstack/SearchQuickstartExample.cs b/local_examples/search_quickstart/nredisstack/SearchQuickstartExample.cs index 94cc75763b..a8bf235cf5 100644 --- a/local_examples/search_quickstart/nredisstack/SearchQuickstartExample.cs +++ b/local_examples/search_quickstart/nredisstack/SearchQuickstartExample.cs @@ -59,7 +59,7 @@ public void Run() " a coaster brake, the Jigger is the vehicle of " + "choice for the rare tenacious little rider " + "raring to go.", - Condition = "used" + Condition = "new" }; // STEP_END @@ -277,8 +277,8 @@ public void Run() // Prints: // refurbished - 1 - // used - 5 - // new - 4 + // used - 4 + // new - 5 // STEP_END // REMOVE_START Assert.Equal(3, result.TotalResults); From 41cf3fdc7d5dc2dba2b81a479325a7277ad1cffb Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Thu, 9 Jul 2026 14:46:42 +0100 Subject: [PATCH 20/28] DOC-6823 make check_tryit_payloads serialize like JSON.stringify (compact, UTF-8) Bugbot correctly flagged that the checker used default json.dumps (spaces after ',' and ':', plus \uXXXX escapes), while the site's Try-it button base64s JSON.stringify output, which is compact and keeps real Unicode. The mismatch overstated payload sizes and could mis-flag near-limit blocks. Switched to separators=(",", ":") + ensure_ascii=False + UTF-8 bytes, matching btoa(unescape(encodeURIComponent(JSON.stringify(cmds)))). This also tightened the calibration: document-database's create_index is now b64=7126, and the observed "Request Line is too large (7163 > 4094)" implies ~37 B of GET/HTTP scaffolding -- matching the checker's conservative --overhead 40. Learned: to size the Try-it button payload accurately, match the browser serialization exactly (JSON.stringify = compact separators + real UTF-8), not Python's default json.dumps; the ~40 B overhead is the full HTTP request line (GET + target + HTTP/1.1), confirmed against the observed 7163 Ticket: DOC-6823 Co-Authored-By: Claude Opus 4.8 (1M context) --- build/check_tryit_payloads.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/build/check_tryit_payloads.py b/build/check_tryit_payloads.py index f02d6edb26..1216d92c29 100644 --- a/build/check_tryit_payloads.py +++ b/build/check_tryit_payloads.py @@ -71,9 +71,15 @@ def _cli_cmds(body): def _payload_b64_len(cmds): - """Bytes of the URL-safe base64 payload the button would send (no padding).""" - js = json.dumps(cmds) - b64 = base64.b64encode(js.encode()).decode().rstrip("=") + """Bytes of the URL-safe base64 payload the button would send (no padding). + + Mirrors the browser's btoa(unescape(encodeURIComponent(JSON.stringify(cmds)))): + JSON.stringify emits compact JSON (no spaces after ',' / ':') and keeps real + Unicode, so use compact separators and ensure_ascii=False + UTF-8 bytes. + Default json.dumps (spaces, \\uXXXX escapes) would overstate the size. + """ + js = json.dumps(cmds, separators=(",", ":"), ensure_ascii=False) + b64 = base64.b64encode(js.encode("utf-8")).decode().rstrip("=") return len(b64) From 1e0445c07876e520adadc5d6f32bc67c8c456d8a Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Thu, 9 Jul 2026 14:51:20 +0100 Subject: [PATCH 21/28] DOC-6823 fix stale 8192 references in check_tryit_payloads docstring/usage The module doc and usage example still described an ~8192-byte payload cap and --limit 8192, but main() defaults to 4094 (the HTTP request-line limit). Running the script as documented would have used a limit ~2x the real redis.io/cli constraint, letting oversized Try-it URLs pass. Docstring now matches the code: ~4094 on the request line, base64 must be <= ~4054, --limit 4094 --overhead 40. Ticket: DOC-6823 Co-Authored-By: Claude Opus 4.8 (1M context) --- build/check_tryit_payloads.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/build/check_tryit_payloads.py b/build/check_tryit_payloads.py index 1216d92c29..fcf4918c12 100644 --- a/build/check_tryit_payloads.py +++ b/build/check_tryit_payloads.py @@ -4,11 +4,17 @@ The external "Try it" button on a `clients-example` block opens `redis.io/cli?commands=&autorun=true`, where is the URL-safe -base64 of a JSON array of the block's CLI commands. That URL payload is capped -at roughly 8192 bytes. Two subtleties this checker accounts for: - - * The cap is on the **base64 payload**, not the raw typed command text - (base64 inflates by ~33%, so ~6 KB of commands can exceed the cap). +base64 of a JSON array of the block's CLI commands. The server rejects the +request with "Request Line is too large (N > 4094)" once the **HTTP request +line** exceeds ~4094 bytes. The request line is `GET /cli?commands=& +autorun=true HTTP/1.1`, i.e. the base64 payload plus ~40 bytes of scaffolding, +so the base64 must be ≲ 4054 bytes. (An early guess of 8192 on the payload was +wrong — the real cap is ~half that, on the request line.) Two subtleties this +checker accounts for: + + * The cap is on the request line / URL, not the raw typed command text. + Sizes are computed to match the browser exactly: base64 of + JSON.stringify(cmds) (compact separators, real UTF-8) — see _payload_b64_len. * A block with `needs_prereq="true"` has its set's `prereq="true"` block prepended by the button, so it inherits the prereq's payload size. @@ -18,10 +24,10 @@ the URL). See the DOC-6823 work on content/develop/get-started/document-database.md. Usage: - python build/check_tryit_payloads.py [--content-dir content] [--limit 8192] - [--warn-frac 0.85] [--quiet] + python build/check_tryit_payloads.py [--content-dir content] [--limit 4094] + [--overhead 40] [--warn-frac 0.85] [--quiet] -Exit status: 1 if any interactive block's payload exceeds the limit, else 0 +Exit status: 1 if any interactive block's request line exceeds the limit, else 0 (so it can gate CI). Stdlib only; run from the repo root. """ From f79c7911a940ff7b7f967bf3889144a0cbf0b931 Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Thu, 9 Jul 2026 14:52:07 +0100 Subject: [PATCH 22/28] DOC-6823 drop the lingering 8192 reference from the checker docstring Removes the historical "early guess of 8192" parenthetical so nothing in the tool still names 8192 as a cap; the request-line limit (4094) is now the only figure mentioned. Closes the last thread in the checker-doc consistency churn. Ticket: DOC-6823 Co-Authored-By: Claude Opus 4.8 (1M context) --- build/check_tryit_payloads.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/build/check_tryit_payloads.py b/build/check_tryit_payloads.py index fcf4918c12..0c3357a54b 100644 --- a/build/check_tryit_payloads.py +++ b/build/check_tryit_payloads.py @@ -8,9 +8,7 @@ request with "Request Line is too large (N > 4094)" once the **HTTP request line** exceeds ~4094 bytes. The request line is `GET /cli?commands=& autorun=true HTTP/1.1`, i.e. the base64 payload plus ~40 bytes of scaffolding, -so the base64 must be ≲ 4054 bytes. (An early guess of 8192 on the payload was -wrong — the real cap is ~half that, on the request line.) Two subtleties this -checker accounts for: +so the base64 must be ≲ 4054 bytes. Two subtleties this checker accounts for: * The cap is on the request line / URL, not the raw typed command text. Sizes are computed to match the browser exactly: base64 of From 983f1d380df47e7ab4105be33679e34590c9683b Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Thu, 9 Jul 2026 15:04:09 +0100 Subject: [PATCH 23/28] DOC-6823 fix swapped brand/model for bicycle:9 in the jedis search override The jedis Bicycle constructor is (brand, model, ...), but bicycle:9 passed ("ThrillCycle", "BikeShind"), indexing brand/model inverted versus the docs and every other client (brand=BikeShind, model=ThrillCycle). Pre-existing upstream defect; the other clients use named fields so were unaffected. No query asserts on bicycle:9's brand/model, so the swap is safe. Ticket: DOC-6823 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../search_quickstart/jedis/SearchQuickstartExample.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/local_examples/search_quickstart/jedis/SearchQuickstartExample.java b/local_examples/search_quickstart/jedis/SearchQuickstartExample.java index ada8c1461a..07d34de399 100644 --- a/local_examples/search_quickstart/jedis/SearchQuickstartExample.java +++ b/local_examples/search_quickstart/jedis/SearchQuickstartExample.java @@ -168,8 +168,8 @@ public void run() { " for money." ), new Bicycle( - "ThrillCycle", "BikeShind", + "ThrillCycle", new BigDecimal(815), "refurbished", "An artsy, retro-inspired bicycle that’s as functional as it is" + From bdc44524931fb33babc0fe648957a940affad007 Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Thu, 9 Jul 2026 15:25:00 +0100 Subject: [PATCH 24/28] DOC-6823 lowercase C# search paths, fix geoindex py import, dispose harness muxer Addresses two Bugbot findings plus a defect surfaced while verifying them: - C# search_quickstart used PascalCase JSON paths ($.Brand, @Model:Jigger) while the docs CLI and every other client use lowercase, so data loaded via the documented JSON.SET would not match the C# index/queries. Lowercased the field identifiers throughout (data props, schema paths + aliases, query fields, result-key access); the "Count" reducer alias is untouched. Verified: the C# example PASSES the harness on Redis 8.8. - Harness dotnet stub GetCleanDatabase connected a new ConnectionMultiplexer per call and never disposed it; now it retains one and disposes it in Dispose(). - (found via verification) geoindex redis-py imported the pre-8 path redis.commands.search.indexDefinition, gone in redis-py 8 (renamed index_definition, which the sibling search_quickstart file already uses). Users copying the tab got ImportError. Fixed; geoindex python now PASSES. Wired search_quickstart + geoindex into the harness: python, go, dotnet PASS for both. Two known non-blocking gaps documented in run.sh: jedis needs a jedis 8.x pom (RedisClient API vs the shared 5.2), and node search_quickstart's wildcard assertion is RediSearch-version-order-dependent (test-only, not docs-visible). Learned: the wildcard-ordering fragility we kept opted-out in the docs also bites the node client's REMOVE-block assertion (documents[0].id) on Redis 8.8 -- a no-SORTBY FT.SEARCH "*" is version-order-dependent everywhere it's asserted Directive: the C#/py/jedis fixes are pre-existing upstream defects in faithful copies -- land them upstream too (redis/NRedisStack, redis/redis-py, redis/jedis) Ticket: DOC-6823 Co-Authored-By: Claude Opus 4.8 (1M context) --- build/example-test-harness/dotnet/stubs.cs | 7 +- build/example-test-harness/run.sh | 15 ++ local_examples/geoindex/redis-py/geo_index.py | 2 +- .../nredisstack/SearchQuickstartExample.cs | 142 +++++++++--------- 4 files changed, 91 insertions(+), 75 deletions(-) diff --git a/build/example-test-harness/dotnet/stubs.cs b/build/example-test-harness/dotnet/stubs.cs index a8d59f6f30..7901bd88ba 100644 --- a/build/example-test-harness/dotnet/stubs.cs +++ b/build/example-test-harness/dotnet/stubs.cs @@ -13,14 +13,15 @@ public enum Env { Standalone } public class AbstractNRedisStackTest : IDisposable { + private ConnectionMultiplexer? _muxer; protected AbstractNRedisStackTest(EndpointsFixture fixture) { } protected void SkipIfTargetConnectionDoesNotExist(EndpointsFixture.Env env) { } protected IDatabase GetCleanDatabase(EndpointsFixture.Env env) { - var muxer = ConnectionMultiplexer.Connect("localhost:6379"); - return muxer.GetDatabase(); + _muxer ??= ConnectionMultiplexer.Connect("localhost:6379"); + return _muxer.GetDatabase(); } - public void Dispose() { } + public void Dispose() { _muxer?.Dispose(); } } // The examples annotate the test method [SkippableFact]; make it a plain Fact. diff --git a/build/example-test-harness/run.sh b/build/example-test-harness/run.sh index 6b6a92dead..32eaddd0d8 100755 --- a/build/example-test-harness/run.sh +++ b/build/example-test-harness/run.sh @@ -71,6 +71,21 @@ src_path() { time_series_tutorial:jedis) echo local_examples/time_series_tutorial/jedis/TimeSeriesTutorialExample.java ;; time_series_tutorial:node) echo local_examples/time_series_tutorial/node-redis/dt-time-series.js ;; time_series_tutorial:dotnet) echo local_examples/time_series_tutorial/nredisstack/TimeSeriesTutorial.cs ;; + # search_quickstart / geoindex: python, go, dotnet PASS. Known gaps (DOC-6823): + # - jedis: examples use the jedis 8.x RedisClient API; the shared pom is 5.2, + # so they don't compile here (same one-off 8.x need as the timeseries jedis). + # - node search_quickstart: its wildcard assertion is RediSearch-version-order- + # dependent (documents[0].id) and fails on Redis 8.8 — a test-only REMOVE-block + # assert, not docs-visible. + search_quickstart:python) echo local_examples/search_quickstart/redis-py/search_quickstart.py ;; + search_quickstart:node) echo local_examples/search_quickstart/node-redis/search-quickstart.js ;; + search_quickstart:go) echo local_examples/search_quickstart/go-redis/search_quickstart_test.go ;; + search_quickstart:jedis) echo local_examples/search_quickstart/jedis/SearchQuickstartExample.java ;; + search_quickstart:dotnet) echo local_examples/search_quickstart/nredisstack/SearchQuickstartExample.cs ;; + geoindex:python) echo local_examples/geoindex/redis-py/geo_index.py ;; + geoindex:go) echo local_examples/geoindex/go-redis/geo_index_test.go ;; + geoindex:jedis) echo local_examples/geoindex/jedis/GeoIndexExample.java ;; + geoindex:dotnet) echo local_examples/geoindex/nredisstack/GeoIndexExample.cs ;; *) echo "" ;; esac } diff --git a/local_examples/geoindex/redis-py/geo_index.py b/local_examples/geoindex/redis-py/geo_index.py index 87a2febf11..3f5359be7a 100644 --- a/local_examples/geoindex/redis-py/geo_index.py +++ b/local_examples/geoindex/redis-py/geo_index.py @@ -2,7 +2,7 @@ import redis from redis.commands.json.path import Path from redis.commands.search.field import TextField, GeoField, GeoShapeField -from redis.commands.search.indexDefinition import IndexDefinition, IndexType +from redis.commands.search.index_definition import IndexDefinition, IndexType from redis.commands.search.query import Query r = redis.Redis() diff --git a/local_examples/search_quickstart/nredisstack/SearchQuickstartExample.cs b/local_examples/search_quickstart/nredisstack/SearchQuickstartExample.cs index a8bf235cf5..73e4504a05 100644 --- a/local_examples/search_quickstart/nredisstack/SearchQuickstartExample.cs +++ b/local_examples/search_quickstart/nredisstack/SearchQuickstartExample.cs @@ -50,16 +50,16 @@ public void Run() // STEP_START data_sample var bike1 = new { - Brand = "Velorim", - Model = "Jigger", - Price = 270M, - Description = "Small and powerful, the Jigger is the best ride " + + brand = "Velorim", + model = "Jigger", + price = 270M, + description = "Small and powerful, the Jigger is the best ride " + "for the smallest of tikes! This is the tiniest " + "kids’ pedal bike on the market available without" + " a coaster brake, the Jigger is the vehicle of " + "choice for the rare tenacious little rider " + "raring to go.", - Condition = "new" + condition = "new" }; // STEP_END @@ -68,35 +68,35 @@ public void Run() bike1, new { - Brand = "Bicyk", - Model = "Hillcraft", - Price = 1200M, - Description = "Kids want to ride with as little weight as possible." + + brand = "Bicyk", + model = "Hillcraft", + price = 1200M, + description = "Kids want to ride with as little weight as possible." + " Especially on an incline! They may be at the age " + "when a 27.5 inch wheel bike is just too clumsy coming " + "off a 24 inch bike. The Hillcraft 26 is just the solution" + " they need!", - Condition = "used", + condition = "used", }, new { - Brand = "Nord", - Model = "Chook air 5", - Price = 815M, - Description = "The Chook Air 5 gives kids aged six years and older " + + brand = "Nord", + model = "Chook air 5", + price = 815M, + description = "The Chook Air 5 gives kids aged six years and older " + "a durable and uberlight mountain bike for their first" + " experience on tracks and easy cruising through forests" + " and fields. The lower top tube makes it easy to mount" + " and dismount in any situation, giving your kids greater" + " safety on the trails.", - Condition = "used", + condition = "used", }, new { - Brand = "Eva", - Model = "Eva 291", - Price = 3400M, - Description = "The sister company to Nord, Eva launched in 2005 as the" + + brand = "Eva", + model = "Eva 291", + price = 3400M, + description = "The sister company to Nord, Eva launched in 2005 as the" + " first and only women-dedicated bicycle brand. Designed" + " by women for women, allEva bikes are optimized for the" + " feminine physique using analytics from a body metrics" + @@ -105,56 +105,56 @@ public void Run() "cross-country ride has been designed for velocity. The" + " 291 has 100mm of front and rear travel, a superlight " + "aluminum frame and fast-rolling 29-inch wheels. Yippee!", - Condition = "used", + condition = "used", }, new { - Brand = "Noka Bikes", - Model = "Kahuna", - Price = 3200M, - Description = "Whether you want to try your hand at XC racing or are " + + brand = "Noka Bikes", + model = "Kahuna", + price = 3200M, + description = "Whether you want to try your hand at XC racing or are " + "looking for a lively trail bike that's just as inspiring" + " on the climbs as it is over rougher ground, the Wilder" + " is one heck of a bike built specifically for short women." + " Both the frames and components have been tweaked to " + "include a women’s saddle, different bars and unique " + "colourway.", - Condition = "used", + condition = "used", }, new { - Brand = "Breakout", - Model = "XBN 2.1 Alloy", - Price = 810M, - Description = "The XBN 2.1 Alloy is our entry-level road bike – but that’s" + + brand = "Breakout", + model = "XBN 2.1 Alloy", + price = 810M, + description = "The XBN 2.1 Alloy is our entry-level road bike – but that’s" + " not to say that it’s a basic machine. With an internal " + "weld aluminium frame, a full carbon fork, and the slick-shifting" + " Claris gears from Shimano’s, this is a bike which doesn’t" + " break the bank and delivers craved performance.", - Condition = "new", + condition = "new", }, new { - Brand = "ScramBikes", - Model = "WattBike", - Price = 2300M, - Description = "An e-bike with a 1000W mid-drive and a 48V battery, offering over 60 miles per charge and three riding modes.", - Condition = "new", + brand = "ScramBikes", + model = "WattBike", + price = 2300M, + description = "An e-bike with a 1000W mid-drive and a 48V battery, offering over 60 miles per charge and three riding modes.", + condition = "new", }, new { - Brand = "Peaknetic", - Model = "Secto", - Price = 430M, - Description = "A lightweight aluminum commuter bike with ergonomic grips, a lumbar-supporting seat, and a low step-over frame for comfort and easy mounting.", - Condition = "new", + brand = "Peaknetic", + model = "Secto", + price = 430M, + description = "A lightweight aluminum commuter bike with ergonomic grips, a lumbar-supporting seat, and a low step-over frame for comfort and easy mounting.", + condition = "new", }, new { - Brand = "nHill", - Model = "Summit", - Price = 1200M, - Description = "This budget mountain bike from nHill performs well both on bike" + + brand = "nHill", + model = "Summit", + price = 1200M, + description = "This budget mountain bike from nHill performs well both on bike" + " paths and on the trail. The fork with 100mm of travel absorbs" + " rough terrain. Fat Kenda Booster tires give you grip in corners" + " and on wet trails. The Shimano Tourney drivetrain offered enough" + @@ -164,31 +164,31 @@ public void Run() " mountains on the weekends or you’re just after a stable," + " comfortable ride for the bike path, the Summit gives a good value" + " for money.", - Condition = "new", + condition = "new", }, new { - Model = "ThrillCycle", - Brand = "BikeShind", - Price = 815M, - Description = "An artsy, retro-inspired bicycle that’s as functional as it is" + + model = "ThrillCycle", + brand = "BikeShind", + price = 815M, + description = "An artsy, retro-inspired bicycle that’s as functional as it is" + " pretty: The ThrillCycle steel frame offers a smooth ride. A" + " 9-speed drivetrain has enough gears for coasting in the city, but" + " we wouldn’t suggest taking it to the mountains. Fenders protect" + " you from mud, and a rear basket lets you transport groceries," + " flowers and books. The ThrillCycle comes with a limited lifetime" + " warranty, so this little guy will last you long past graduation.", - Condition = "refurbished", + condition = "refurbished", }, }; // STEP_START create_index var schema = new Schema() - .AddTextField(new FieldName("$.Brand", "Brand")) - .AddTextField(new FieldName("$.Model", "Model")) - .AddTextField(new FieldName("$.Description", "Description")) - .AddNumericField(new FieldName("$.Price", "Price")) - .AddTagField(new FieldName("$.Condition", "Condition")); + .AddTextField(new FieldName("$.brand", "brand")) + .AddTextField(new FieldName("$.model", "model")) + .AddTextField(new FieldName("$.description", "description")) + .AddNumericField(new FieldName("$.price", "price")) + .AddTagField(new FieldName("$.condition", "condition")); ft.Create( "idx:bicycle", @@ -212,13 +212,13 @@ public void Run() // REMOVE_END // STEP_START query_single_term - var query2 = new Query("@Model:Jigger"); + var query2 = new Query("@model:Jigger"); var res2 = ft.Search("idx:bicycle", query2).Documents; Console.WriteLine(string.Join("\n", res2.Select(x => x["json"]))); - // Prints: {"Brand":"Moore PLC","Model":"Award Race","Price":3790.76, - // "Description":"This olive folding bike features a carbon frame + // Prints: {"brand":"Moore PLC","model":"Award Race","price":3790.76, + // "description":"This olive folding bike features a carbon frame // and 27.5 inch wheels. This folding bike is perfect for compact - // storage and transportation.","Condition":"new"} + // storage and transportation.","condition":"new"} // STEP_END // REMOVE_START Assert.Single(res2); @@ -226,13 +226,13 @@ public void Run() // REMOVE_END // STEP_START query_single_term_and_num_range - var query3 = new Query("basic @Price:[500 1000]"); + var query3 = new Query("basic @price:[500 1000]"); var res3 = ft.Search("idx:bicycle", query3).Documents; Console.WriteLine(string.Join("\n", res3.Select(x => x["json"]))); - // Prints: {"Brand":"Moore PLC","Model":"Award Race","Price":3790.76, - // "Description":"This olive folding bike features a carbon frame + // Prints: {"brand":"Moore PLC","model":"Award Race","price":3790.76, + // "description":"This olive folding bike features a carbon frame // and 27.5 inch wheels. This folding bike is perfect for compact - // storage and transportation.","Condition":"new"} + // storage and transportation.","condition":"new"} // STEP_END // REMOVE_START Assert.Single(res3); @@ -240,13 +240,13 @@ public void Run() // REMOVE_END // STEP_START query_exact_matching - var query4 = new Query("@Brand:\"Noka Bikes\""); + var query4 = new Query("@brand:\"Noka Bikes\""); var res4 = ft.Search("idx:bicycle", query4).Documents; Console.WriteLine(string.Join("\n", res4.Select(x => x["json"]))); - // Prints: {"Brand":"Moore PLC","Model":"Award Race","Price":3790.76, - // "Description":"This olive folding bike features a carbon frame + // Prints: {"brand":"Moore PLC","model":"Award Race","price":3790.76, + // "description":"This olive folding bike features a carbon frame // and 27.5 inch wheels. This folding bike is perfect for compact - // storage and transportation.","Condition":"new"} + // storage and transportation.","condition":"new"} // STEP_END // REMOVE_START Assert.Single(res4); @@ -254,9 +254,9 @@ public void Run() // REMOVE_END // STEP_START query_single_term_limit_fields - var query5 = new Query("@Model:Jigger").ReturnFields("Price"); + var query5 = new Query("@model:Jigger").ReturnFields("price"); var res5 = ft.Search("idx:bicycle", query5).Documents; - Console.WriteLine(res5.First()["Price"]); + Console.WriteLine(res5.First()["price"]); // Prints: 270 // STEP_END // REMOVE_START @@ -266,13 +266,13 @@ public void Run() // STEP_START simple_aggregation var request = new AggregationRequest("*").GroupBy( - "@Condition", Reducers.Count().As("Count")); + "@condition", Reducers.Count().As("Count")); var result = ft.Aggregate("idx:bicycle", request); for (var i = 0; i < result.TotalResults; i++) { var row = result.GetRow(i); - Console.WriteLine($"{row["Condition"]} - {row["Count"]}"); + Console.WriteLine($"{row["condition"]} - {row["Count"]}"); } // Prints: From c75fe58510bea25346eeff8110f3a68684b0f07e Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Thu, 9 Jul 2026 15:37:44 +0100 Subject: [PATCH 25/28] DOC-6823 bump harness jedis to 7.5.3 so RedisClient examples compile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The harness mapped geoindex/search_quickstart (and, pre-existing, time_series) to jedis examples that use the jedis RedisClient API, but pom-jedis.xml pinned 5.2, so those rows failed at compile — the matrix implied a runnable jedis client that wasn't (Bugbot 3552419502). jedis 7.5.3 (latest stable) ships BOTH RedisClient and the older UnifiedJedis, so a single shared-pom bump fixes it without a per-example pom. Verified every jedis row now PASSES on Redis 8.8: ss/set/hash/ sets/time_series (UnifiedJedis) + search_quickstart/geoindex/time_series (RedisClient). Only remaining harness gap is node search_quickstart's version-order-dependent wildcard assertion (test-only, not docs-visible). Learned: jedis RedisClient (the newer client) is available from jedis 7.x, not only 8.x, and 7.5.3 keeps UnifiedJedis too -- so one pom serves both old and new examples; my earlier "one-off 8.x bump for TS jedis" note was wrong (5.2 simply lacked RedisClient, and the TS pass had been a manual work-dir edit) Ticket: DOC-6823 Co-Authored-By: Claude Opus 4.8 (1M context) --- build/example-test-harness/pom-jedis.xml | 2 +- build/example-test-harness/run.sh | 11 +++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/build/example-test-harness/pom-jedis.xml b/build/example-test-harness/pom-jedis.xml index 2df33293e0..d442649664 100644 --- a/build/example-test-harness/pom-jedis.xml +++ b/build/example-test-harness/pom-jedis.xml @@ -4,7 +4,7 @@ io.redisjedis1.0 17UTF-8 - redis.clientsjedis5.2.0 + redis.clientsjedis7.5.3 org.junit.jupiterjunit-jupiter5.10.2test org.assertjassertj-core3.25.3test diff --git a/build/example-test-harness/run.sh b/build/example-test-harness/run.sh index 32eaddd0d8..60a8b9d722 100755 --- a/build/example-test-harness/run.sh +++ b/build/example-test-harness/run.sh @@ -71,12 +71,11 @@ src_path() { time_series_tutorial:jedis) echo local_examples/time_series_tutorial/jedis/TimeSeriesTutorialExample.java ;; time_series_tutorial:node) echo local_examples/time_series_tutorial/node-redis/dt-time-series.js ;; time_series_tutorial:dotnet) echo local_examples/time_series_tutorial/nredisstack/TimeSeriesTutorial.cs ;; - # search_quickstart / geoindex: python, go, dotnet PASS. Known gaps (DOC-6823): - # - jedis: examples use the jedis 8.x RedisClient API; the shared pom is 5.2, - # so they don't compile here (same one-off 8.x need as the timeseries jedis). - # - node search_quickstart: its wildcard assertion is RediSearch-version-order- - # dependent (documents[0].id) and fails on Redis 8.8 — a test-only REMOVE-block - # assert, not docs-visible. + # search_quickstart / geoindex: python, go, jedis, dotnet PASS (pom-jedis.xml is + # 7.5.3, which has both the RedisClient API these examples use and the older + # UnifiedJedis API). One known gap (DOC-6823): node search_quickstart's wildcard + # assertion is RediSearch-version-order-dependent (documents[0].id) and fails on + # Redis 8.8 — a test-only REMOVE-block assert, not docs-visible. search_quickstart:python) echo local_examples/search_quickstart/redis-py/search_quickstart.py ;; search_quickstart:node) echo local_examples/search_quickstart/node-redis/search-quickstart.js ;; search_quickstart:go) echo local_examples/search_quickstart/go-redis/search_quickstart_test.go ;; From b6cb8824fe89cc828476900eae22dc3eeb6338b8 Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Thu, 9 Jul 2026 15:43:54 +0100 Subject: [PATCH 26/28] DOC-6823 fail the harness loudly on unreachable Redis / failed flush The drive loop ran redis-cli flushall ignoring its exit status, so a failed flush (Redis down, wrong host, permissions) would let examples run against leftover keys and pass/fail unpredictably (Bugbot 3552481065). Added a fail-fast ping check at startup and made the per-client flushall abort with a clear message if it fails, rather than testing against stale state. Verified a normal run (Redis up) is unaffected. Ticket: DOC-6823 Co-Authored-By: Claude Opus 4.8 (1M context) --- build/example-test-harness/run.sh | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/build/example-test-harness/run.sh b/build/example-test-harness/run.sh index 60a8b9d722..9fc1aabe2b 100755 --- a/build/example-test-harness/run.sh +++ b/build/example-test-harness/run.sh @@ -177,12 +177,23 @@ PHP # --- drive -------------------------------------------------------------------- log "=== TCE sweep: $SET (redis @ localhost:6379) ===" +# Fail fast if the scratch Redis is unreachable — every run FLUSHes it and relies +# on a clean db, so silently proceeding would give misleading pass/fail results. +if ! redis-cli ping >/dev/null 2>&1; then + log "ERROR: no Redis responding on localhost:6379 — start a throwaway Redis first." + exit 1 +fi mkdir -p "$HARNESS/results" for c in "${CLIENTS[@]}"; do rel="$(src_path "$SET" "$c")" if [ -z "$rel" ] || [ ! -f "$REPO/$rel" ]; then SUMMARY+=("$c SKIP (no source)"); log ">> $c: SKIP"; continue; fi LOG="$HARNESS/results/${SET}_${c}.log"; rc=1 - redis-cli flushall >/dev/null 2>&1 + # A failed flush means stale keys leak into the next example -> unreliable + # results, so abort loudly rather than test against leftover state. + if ! redis-cli flushall >/dev/null 2>&1; then + log "ERROR: 'redis-cli flushall' failed before $c — aborting to avoid testing against stale keys." + exit 1 + fi log ">> $c: running..." "run_${c//-/_}" "$REPO/$rel" if [ "${rc:-1}" -eq 0 ]; then SUMMARY+=("$c PASS"); log ">> $c: PASS" From 903c0d0e384f8e3f2690e02918d5bf8fde893758 Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Thu, 9 Jul 2026 15:57:21 +0100 Subject: [PATCH 27/28] DOC-6823 complete the go search_quickstart index schema (price, condition) The go FT.CREATE indexed only brand/model/description, omitting the price (NUMERIC) and condition (TAG) fields that the docs CLI and the jedis/python/C# examples define, so the go tab taught an incomplete index and numeric/tag queries against it would not behave like the tutorial (Bugbot 3552548341). Added the two fields. go's own steps only query @model/@brand/wildcard, so it still passes; the fix is for cross-client consistency and correctness. Ticket: DOC-6823 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../go-redis/search_quickstart_test.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/local_examples/search_quickstart/go-redis/search_quickstart_test.go b/local_examples/search_quickstart/go-redis/search_quickstart_test.go index 18a6228578..b0b362bde4 100644 --- a/local_examples/search_quickstart/go-redis/search_quickstart_test.go +++ b/local_examples/search_quickstart/go-redis/search_quickstart_test.go @@ -164,6 +164,16 @@ func ExampleClient_search_qs() { As: "description", FieldType: redis.SearchFieldTypeText, }, + { + FieldName: "$.price", + As: "price", + FieldType: redis.SearchFieldTypeNumeric, + }, + { + FieldName: "$.condition", + As: "condition", + FieldType: redis.SearchFieldTypeTag, + }, } _, err := rdb.FTCreate(ctx, "idx:bicycle", From e815e44e3856bd98120e345357a3cacdbf29f214 Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Thu, 9 Jul 2026 16:16:41 +0100 Subject: [PATCH 28/28] DOC-6823 order-independent node wildcard assert + harness exits non-zero on FAIL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Bugbot findings: - node search_quickstart's wildcard REMOVE-block assert checked result.documents[0].id == "bicycle:0", but FT.SEARCH "*" has no guaranteed order, so it failed on Redis 8.8 (3552632694). Changed it to assert result.total == 10, which is what the "retrieve all documents" step actually demonstrates and is order-independent. node search_quickstart now PASSES, closing the last known harness gap. (The identical line in query_single_term is a single @model:Jigger result, so its docs[0] check is fine and untouched.) - run.sh recorded FAIL in the summary but always exited 0, so it couldn't gate CI (3552632685). Added `exit 1` when any client FAILed (0 otherwise). Verified: a clean run exits 0. Learned: FT.SEARCH "*" ordering is unspecified — assert result counts/sets, never documents[0], for wildcard steps (this is the same fragility that keeps the docs wildcard opted-out) Ticket: DOC-6823 Co-Authored-By: Claude Opus 4.8 (1M context) --- build/example-test-harness/run.sh | 12 +++++++----- .../node-redis/search-quickstart.js | 3 ++- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/build/example-test-harness/run.sh b/build/example-test-harness/run.sh index 9fc1aabe2b..406c41a0de 100755 --- a/build/example-test-harness/run.sh +++ b/build/example-test-harness/run.sh @@ -71,11 +71,9 @@ src_path() { time_series_tutorial:jedis) echo local_examples/time_series_tutorial/jedis/TimeSeriesTutorialExample.java ;; time_series_tutorial:node) echo local_examples/time_series_tutorial/node-redis/dt-time-series.js ;; time_series_tutorial:dotnet) echo local_examples/time_series_tutorial/nredisstack/TimeSeriesTutorial.cs ;; - # search_quickstart / geoindex: python, go, jedis, dotnet PASS (pom-jedis.xml is - # 7.5.3, which has both the RedisClient API these examples use and the older - # UnifiedJedis API). One known gap (DOC-6823): node search_quickstart's wildcard - # assertion is RediSearch-version-order-dependent (documents[0].id) and fails on - # Redis 8.8 — a test-only REMOVE-block assert, not docs-visible. + # search_quickstart / geoindex: all mapped clients PASS (pom-jedis.xml is 7.5.3, + # which has both the RedisClient API these examples use and the older UnifiedJedis + # API; the node wildcard assert now checks result.total, not doc order). search_quickstart:python) echo local_examples/search_quickstart/redis-py/search_quickstart.py ;; search_quickstart:node) echo local_examples/search_quickstart/node-redis/search-quickstart.js ;; search_quickstart:go) echo local_examples/search_quickstart/go-redis/search_quickstart_test.go ;; @@ -202,3 +200,7 @@ done log ""; log "=== RESULTS: $SET ===" for e in "${SUMMARY[@]}"; do printf ' %-18s %s\n' "${e%% *}" "${e#* }"; done + +# Exit non-zero if any client FAILed, so the harness can gate CI. +for e in "${SUMMARY[@]}"; do case "$e" in *FAIL*) exit 1;; esac; done +exit 0 diff --git a/local_examples/search_quickstart/node-redis/search-quickstart.js b/local_examples/search_quickstart/node-redis/search-quickstart.js index 8c5b4553dd..e1d38ef71c 100644 --- a/local_examples/search_quickstart/node-redis/search-quickstart.js +++ b/local_examples/search_quickstart/node-redis/search-quickstart.js @@ -156,7 +156,8 @@ console.log(JSON.stringify(result, null, 2)); // STEP_END // REMOVE_START -assert.equal(result.documents[0].id, 'bicycle:0'); +// FT.SEARCH "*" order is not guaranteed; assert the full result count instead. +assert.equal(result.total, 10); // REMOVE_END // STEP_START query_single_term