diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 7cffe01a99220f5514ce2426b0e4e7bb362a0962..2e9c3f9439d34c9677fc0f7feca1488fc068ca8f 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -16,7 +16,7 @@ jobs: - name: Check out code uses: actions/checkout@v2 - name: Set up Ruby - uses: actions/setup-ruby@v1 + uses: ruby/setup-ruby@v1 with: ruby-version: "2.4" - name: Set up Gems @@ -34,8 +34,8 @@ jobs: fail-fast: false matrix: os: ["ubuntu-latest"] - redis: ["6.0"] - ruby: ["2.7", "2.6", "2.5", "2.4"] + redis: ["6.2"] + ruby: ["3.0", "2.7", "2.6", "2.5"] driver: ["ruby", "hiredis", "synchrony"] runs-on: ${{ matrix.os }} env: @@ -54,7 +54,7 @@ jobs: echo "DRIVER=${DRIVER}" echo "REDIS_BRANCH=${REDIS_BRANCH}" - name: Set up Ruby - uses: actions/setup-ruby@v1 + uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby }} - name: Cache dependent gems @@ -87,8 +87,8 @@ jobs: fail-fast: false matrix: os: ["ubuntu-latest"] - redis: ["5.0", "4.0", "3.2", "3.0"] - ruby: ["jruby-9.2.9.0", "2.3"] + redis: ["6.0", "5.0", "4.0", "3.2", "3.0"] + ruby: ["jruby-9.2.9.0", "2.4"] driver: ["ruby"] runs-on: ${{ matrix.os }} env: diff --git a/.rubocop.yml b/.rubocop.yml index 96fb535ce441d84831c315a23f22c83c61404812..7942ca64b6fa53e86f3f4b6b10808d62952f2ca7 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,5 +1,5 @@ AllCops: - TargetRubyVersion: 2.3 + TargetRubyVersion: 2.4 Layout/LineLength: Max: 120 @@ -100,12 +100,15 @@ Style/WordArray: Lint/NonLocalExitFromIterator: Enabled: false -Lint/EndAlignment: +Layout/EndAlignment: EnforcedStyleAlignWith: variable Layout/ElseAlignment: Enabled: false +Layout/RescueEnsureAlignment: + Enabled: false + Naming/HeredocDelimiterNaming: Enabled: false @@ -121,3 +124,6 @@ Naming/AccessorMethodName: Naming/MethodParameterName: Enabled: false + +Metrics/BlockNesting: + Enabled: false diff --git a/CHANGELOG.md b/CHANGELOG.md index 5973df5ed8460cf334482782c3025d018c00c6b3..b83ac83c5ea0da7c838fb5277a6ede3d526e9ff3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,43 @@ # Unreleased +# 4.5.1 + +* Restore the accidential auth behavior of redis-rb 4.3.0 with a warning. If provided with the `default` user's password, but a wrong username, + redis-rb will first try to connect as the provided user, but then will fallback to connect as the `default` user with the provided password. + This behavior is deprecated and will be removed in Redis 4.6.0. Fix #1038. + +# 4.5.0 + +* Handle parts of the command using incompatible encodings. See #1037. +* Add GET option to SET command. See #1036. +* Add ZRANDMEMBER command. See #1035. +* Add LMOVE/BLMOVE commands. See #1034. +* Add ZMSCORE command. See #1032. +* Add LT/GT options to ZADD. See #1033. +* Add SMISMEMBER command. See #1031. +* Add EXAT/PXAT options to SET. See #1028. +* Add GETDEL/GETEX commands. See #1024. +* `Redis#exists` now returns an Integer by default, as warned since 4.2.0. The old behavior can be restored with `Redis.exists_returns_integer = false`. +* Fix Redis < 6 detection during connect. See #1025. +* Fix fetching command details in Redis cluster when the first node is unhealthy. See #1026. + +# 4.4.0 + +* Redis cluster: fix cross-slot validation in pipelines. Fix ##1019. +* Add support for `XAUTOCLAIM`. See #1018. +* Properly issue `READONLY` when reconnecting to replicas. Fix #1017. +* Make `del` a noop if passed an empty list of keys. See #998. +* Add support for `ZINTER`. See #995. + +# 4.3.1 + +* Fix password authentication against redis server 5 and older. + +# 4.3.0 + +* Add the TYPE argument to scan and scan_each. See #985. +* Support AUTH command for ACL. See #967. + # 4.2.5 * Optimize the ruby connector write buffering. See #964. diff --git a/README.md b/README.md index f313033d53e570fd97278f2ea626b03fffdc2f1c..b8591ca50390deff470a21f3faeda92a1f3b5a54 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# redis-rb [![Build Status][travis-image]][travis-link] [![Inline docs][inchpages-image]][inchpages-link]  +# redis-rb [![Build Status][gh-actions-image]][gh-actions-link] [![Inline docs][inchpages-image]][inchpages-link] A Ruby client that tries to match [Redis][redis-home]' API one-to-one, while still providing an idiomatic interface. @@ -54,6 +54,12 @@ To connect to a password protected Redis instance, use: redis = Redis.new(password: "mysecret") ``` +To connect a Redis instance using [ACL](https://redis.io/topics/acl), use: + +```ruby +redis = Redis.new(username: 'myname', password: 'mysecret') +``` + The Redis class exports methods that are named identical to the commands they execute. The arguments these methods accept are often identical to the arguments specified on the [Redis website][redis-commands]. For @@ -440,7 +446,7 @@ redis = Redis.new(:driver => :synchrony) ## Testing This library is tested against recent Ruby and Redis versions. -Check [Travis][travis-link] for the exact versions supported. +Check [Github Actions][gh-actions-link] for the exact versions supported. ## See Also @@ -459,12 +465,11 @@ client and evangelized Redis in Rubyland. Thank you, Ezra. requests. -[inchpages-image]: https://inch-ci.org/github/redis/redis-rb.svg -[inchpages-link]: https://inch-ci.org/github/redis/redis-rb -[redis-commands]: https://redis.io/commands -[redis-home]: https://redis.io -[redis-url]: http://www.iana.org/assignments/uri-schemes/prov/redis -[travis-home]: https://travis-ci.org/ -[travis-image]: https://secure.travis-ci.org/redis/redis-rb.svg?branch=master -[travis-link]: https://travis-ci.org/redis/redis-rb -[rubydoc]: http://www.rubydoc.info/gems/redis +[inchpages-image]: https://inch-ci.org/github/redis/redis-rb.svg +[inchpages-link]: https://inch-ci.org/github/redis/redis-rb +[redis-commands]: https://redis.io/commands +[redis-home]: https://redis.io +[redis-url]: http://www.iana.org/assignments/uri-schemes/prov/redis +[gh-actions-image]: https://github.com/redis/redis-rb/workflows/Test/badge.svg +[gh-actions-link]: https://github.com/redis/redis-rb/actions +[rubydoc]: http://www.rubydoc.info/gems/redis diff --git a/Rakefile b/Rakefile index 268b1568d6375961a355aab4cf28efd3245ab268..fa56d55c47964c30c0e94ee595f9113932a1323b 100644 --- a/Rakefile +++ b/Rakefile @@ -2,18 +2,8 @@ require 'bundler/gem_tasks' require 'rake/testtask' -Rake::TestTask.new :test do |t| - if ENV['SOCKET_PATH'].nil? - sock_file = Dir.glob("#{__dir__}/**/redis.sock").first - - if sock_file.nil? - puts '`SOCKET_PATH` environment variable required' - exit 1 - end - - ENV['SOCKET_PATH'] = sock_file - end +Rake::TestTask.new :test do |t| t.libs = %w(lib test) if ARGV.size == 1 @@ -25,4 +15,21 @@ Rake::TestTask.new :test do |t| t.options = '-v' if ENV['CI'] || ENV['VERBOSE'] end +namespace :test do + task :set_socket_path do + if ENV['SOCKET_PATH'].nil? + sock_file = Dir.glob("#{__dir__}/**/redis.sock").first + + if sock_file.nil? + puts '`SOCKET_PATH` environment variable required' + exit 1 + end + + ENV['SOCKET_PATH'] = sock_file + end + end +end + +Rake::Task[:test].enhance(["test:set_socket_path"]) + task default: :test diff --git a/benchmarking/speed.rb b/benchmarking/speed.rb index f8ab1587972934dc4840145b9a1fbb116f30592f..cb5bc46a74fff5791ff2bfb2bbf4ec3872829795 100644 --- a/benchmarking/speed.rb +++ b/benchmarking/speed.rb @@ -1,9 +1,6 @@ # frozen_string_literal: true -# Run with -# -# $ ruby -Ilib benchmarking/speed.rb -# +$LOAD_PATH.push File.join(__dir__, 'lib') require "benchmark" require "redis" @@ -15,8 +12,8 @@ elapsed = Benchmark.realtime do # n sets, n gets n.times do |i| key = "foo#{i}" - r[key] = key * 10 - r[key] + r.set(key, key * 10) + r.get(key) end end diff --git a/bin/build b/bin/build index 7f2c788ab8da4624caada53135d2fb7caf69b23c..925a91059a68473bcc30a88c53d8db0e05923d5c 100755 --- a/bin/build +++ b/bin/build @@ -4,6 +4,7 @@ TARBALL = ARGV[0] require 'digest/sha1' +require 'English' require 'fileutils' class Builder diff --git a/lib/redis.rb b/lib/redis.rb index 8410ac8b6807dbd89d691a0a9ec4828f588d89cd..5ae2e387a088842839798632c2357f325e585ac6 100644 --- a/lib/redis.rb +++ b/lib/redis.rb @@ -4,6 +4,8 @@ require "monitor" require_relative "redis/errors" class Redis + @exists_returns_integer = true + class << self attr_reader :exists_returns_integer @@ -39,6 +41,7 @@ class Redis # @option options [String] :path path to server socket (overrides host and port) # @option options [Float] :timeout (5.0) timeout in seconds # @option options [Float] :connect_timeout (same as timeout) timeout for initial connect in seconds + # @option options [String] :username Username to authenticate against server # @option options [String] :password Password to authenticate against server # @option options [Integer] :db (0) Database to select after initial connect # @option options [Symbol] :driver Driver to use, currently supported: `:ruby`, `:hiredis`, `:synchrony` @@ -143,12 +146,13 @@ class Redis # Authenticate to the server. # - # @param [String] password must match the password specified in the - # `requirepass` directive in the configuration file + # @param [Array<String>] args includes both username and password + # or only password # @return [String] `OK` - def auth(password) + # @see https://redis.io/commands/auth AUTH command + def auth(*args) synchronize do |client| - client.call([:auth, password]) + client.call([:auth, *args]) end end @@ -553,6 +557,9 @@ class Redis # @param [String, Array<String>] keys # @return [Integer] number of keys that were deleted def del(*keys) + keys.flatten!(1) + return 0 if keys.empty? + synchronize do |client| client.call([:del] + keys) end @@ -829,17 +836,23 @@ class Redis # @param [Hash] options # - `:ex => Integer`: Set the specified expire time, in seconds. # - `:px => Integer`: Set the specified expire time, in milliseconds. + # - `:exat => Integer` : Set the specified Unix time at which the key will expire, in seconds. + # - `:pxat => Integer` : Set the specified Unix time at which the key will expire, in milliseconds. # - `:nx => true`: Only set the key if it does not already exist. # - `:xx => true`: Only set the key if it already exist. # - `:keepttl => true`: Retain the time to live associated with the key. + # - `:get => true`: Return the old string stored at key, or nil if key did not exist. # @return [String, Boolean] `"OK"` or true, false if `:nx => true` or `:xx => true` - def set(key, value, ex: nil, px: nil, nx: nil, xx: nil, keepttl: nil) + def set(key, value, ex: nil, px: nil, exat: nil, pxat: nil, nx: nil, xx: nil, keepttl: nil, get: nil) args = [:set, key, value.to_s] args << "EX" << ex if ex args << "PX" << px if px + args << "EXAT" << exat if exat + args << "PXAT" << pxat if pxat args << "NX" if nx args << "XX" if xx args << "KEEPTTL" if keepttl + args << "GET" if get synchronize do |client| if nx || xx @@ -1105,6 +1118,45 @@ class Redis end end + # Get the value of key and delete the key. This command is similar to GET, + # except for the fact that it also deletes the key on success. + # + # @param [String] key + # @return [String] the old value stored in the key, or `nil` if the key + # did not exist + def getdel(key) + synchronize do |client| + client.call([:getdel, key]) + end + end + + # Get the value of key and optionally set its expiration. GETEX is similar to + # GET, but is a write command with additional options. When no options are + # provided, GETEX behaves like GET. + # + # @param [String] key + # @param [Hash] options + # - `:ex => Integer`: Set the specified expire time, in seconds. + # - `:px => Integer`: Set the specified expire time, in milliseconds. + # - `:exat => true`: Set the specified Unix time at which the key will + # expire, in seconds. + # - `:pxat => true`: Set the specified Unix time at which the key will + # expire, in milliseconds. + # - `:persist => true`: Remove the time to live associated with the key. + # @return [String] The value of key, or nil when key does not exist. + def getex(key, ex: nil, px: nil, exat: nil, pxat: nil, persist: false) + args = [:getex, key] + args << "EX" << ex if ex + args << "PX" << px if px + args << "EXAT" << exat if exat + args << "PXAT" << pxat if pxat + args << "PERSIST" if persist + + synchronize do |client| + client.call(args) + end + end + # Get the length of the value stored in a key. # # @param [String] key @@ -1126,6 +1178,59 @@ class Redis end end + # Remove the first/last element in a list, append/prepend it to another list and return it. + # + # @param [String] source source key + # @param [String] destination destination key + # @param [String, Symbol] where_source from where to remove the element from the source list + # e.g. 'LEFT' - from head, 'RIGHT' - from tail + # @param [String, Symbol] where_destination where to push the element to the source list + # e.g. 'LEFT' - to head, 'RIGHT' - to tail + # + # @return [nil, String] the element, or nil when the source key does not exist + # + # @note This command comes in place of the now deprecated RPOPLPUSH. + # Doing LMOVE RIGHT LEFT is equivalent. + def lmove(source, destination, where_source, where_destination) + where_source, where_destination = _normalize_move_wheres(where_source, where_destination) + + synchronize do |client| + client.call([:lmove, source, destination, where_source, where_destination]) + end + end + + # Remove the first/last element in a list and append/prepend it + # to another list and return it, or block until one is available. + # + # @example With timeout + # element = redis.blmove("foo", "bar", "LEFT", "RIGHT", timeout: 5) + # # => nil on timeout + # # => "element" on success + # @example Without timeout + # element = redis.blmove("foo", "bar", "LEFT", "RIGHT") + # # => "element" + # + # @param [String] source source key + # @param [String] destination destination key + # @param [String, Symbol] where_source from where to remove the element from the source list + # e.g. 'LEFT' - from head, 'RIGHT' - from tail + # @param [String, Symbol] where_destination where to push the element to the source list + # e.g. 'LEFT' - to head, 'RIGHT' - to tail + # @param [Hash] options + # - `:timeout => Numeric`: timeout in seconds, defaults to no timeout + # + # @return [nil, String] the element, or nil when the source key does not exist or the timeout expired + # + def blmove(source, destination, where_source, where_destination, timeout: 0) + where_source, where_destination = _normalize_move_wheres(where_source, where_destination) + + synchronize do |client| + command = [:blmove, source, destination, where_source, where_destination, timeout] + timeout += client.timeout if timeout > 0 + client.call_with_timeout(command, timeout) + end + end + # Prepend one or more values to a list, creating the list if it doesn't exist # # @param [String] key @@ -1170,23 +1275,29 @@ class Redis end end - # Remove and get the first element in a list. + # Remove and get the first elements in a list. # # @param [String] key - # @return [String] - def lpop(key) + # @param [Integer] count number of elements to remove + # @return [String, Array<String>] the values of the first elements + def lpop(key, count = nil) synchronize do |client| - client.call([:lpop, key]) + command = [:lpop, key] + command << count if count + client.call(command) end end - # Remove and get the last element in a list. + # Remove and get the last elements in a list. # # @param [String] key - # @return [String] - def rpop(key) + # @param [Integer] count number of elements to remove + # @return [String, Array<String>] the values of the last elements + def rpop(key, count = nil) synchronize do |client| - client.call([:rpop, key]) + command = [:rpop, key] + command << count if count + client.call(command) end end @@ -1468,6 +1579,19 @@ class Redis end end + # Determine if multiple values are members of a set. + # + # @param [String] key + # @param [String, Array<String>] members + # @return [Array<Boolean>] + def smismember(key, *members) + synchronize do |client| + client.call([:smismember, key, *members]) do |reply| + reply.map(&Boolify) + end + end + end + # Get all the members in a set. # # @param [String] key @@ -1572,6 +1696,10 @@ class Redis # add elements) # - `:nx => true`: Don't update already existing elements (always # add new elements) + # - `:lt => true`: Only update existing elements if the new score + # is less than the current score + # - `:gt => true`: Only update existing elements if the new score + # is greater than the current score # - `:ch => true`: Modify the return value from the number of new # elements added, to the total number of elements changed (CH is an # abbreviation of changed); changed elements are new elements added @@ -1586,10 +1714,12 @@ class Redis # pairs that were **added** to the sorted set. # - `Float` when option :incr is specified, holding the score of the member # after incrementing it. - def zadd(key, *args, nx: nil, xx: nil, ch: nil, incr: nil) + def zadd(key, *args, nx: nil, xx: nil, lt: nil, gt: nil, ch: nil, incr: nil) command = [:zadd, key] command << "NX" if nx command << "XX" if xx + command << "LT" if lt + command << "GT" if gt command << "CH" if ch command << "INCR" if incr @@ -1752,6 +1882,63 @@ class Redis end end + # Get the scores associated with the given members in a sorted set. + # + # @example Get the scores for members "a" and "b" + # redis.zmscore("zset", "a", "b") + # # => [32.0, 48.0] + # + # @param [String] key + # @param [String, Array<String>] members + # @return [Array<Float>] scores of the members + def zmscore(key, *members) + synchronize do |client| + client.call([:zmscore, key, *members]) do |reply| + reply.map(&Floatify) + end + end + end + + # Get one or more random members from a sorted set. + # + # @example Get one random member + # redis.zrandmember("zset") + # # => "a" + # @example Get multiple random members + # redis.zrandmember("zset", 2) + # # => ["a", "b"] + # @example Gem multiple random members with scores + # redis.zrandmember("zset", 2, with_scores: true) + # # => [["a", 2.0], ["b", 3.0]] + # + # @param [String] key + # @param [Integer] count + # @param [Hash] options + # - `:with_scores => true`: include scores in output + # + # @return [nil, String, Array<String>, Array<[String, Float]>] + # - when `key` does not exist or set is empty, `nil` + # - when `count` is not specified, a member + # - when `count` is specified and `:with_scores` is not specified, an array of members + # - when `:with_scores` is specified, an array with `[member, score]` pairs + def zrandmember(key, count = nil, withscores: false, with_scores: withscores) + if with_scores && count.nil? + raise ArgumentError, "count argument must be specified" + end + + args = [:zrandmember, key] + args << count if count + + if with_scores + args << "WITHSCORES" + block = FloatifyPairs + end + + synchronize do |client| + client.call(args, &block) + end + end + # Return a range of members in a sorted set, by index. # # @example Retrieve all members from a sorted set @@ -2054,6 +2241,45 @@ class Redis end end + # Return the intersection of multiple sorted sets + # + # @example Retrieve the intersection of `2*zsetA` and `1*zsetB` + # redis.zinter("zsetA", "zsetB", :weights => [2.0, 1.0]) + # # => ["v1", "v2"] + # @example Retrieve the intersection of `2*zsetA` and `1*zsetB`, and their scores + # redis.zinter("zsetA", "zsetB", :weights => [2.0, 1.0], :with_scores => true) + # # => [["v1", 3.0], ["v2", 6.0]] + # + # @param [String, Array<String>] keys one or more keys to intersect + # @param [Hash] options + # - `:weights => [Float, Float, ...]`: weights to associate with source + # sorted sets + # - `:aggregate => String`: aggregate function to use (sum, min, max, ...) + # - `:with_scores => true`: include scores in output + # + # @return [Array<String>, Array<[String, Float]>] + # - when `:with_scores` is not specified, an array of members + # - when `:with_scores` is specified, an array with `[member, score]` pairs + def zinter(*keys, weights: nil, aggregate: nil, with_scores: false) + args = [:zinter, keys.size, *keys] + + if weights + args << "WEIGHTS" + args.concat(weights) + end + + args << "AGGREGATE" << aggregate if aggregate + + if with_scores + args << "WITHSCORES" + block = FloatifyPairs + end + + synchronize do |client| + client.call(args, &block) + end + end + # Intersect multiple sorted sets and store the resulting sorted set in a new # key. # @@ -2636,12 +2862,13 @@ class Redis _eval(:evalsha, args) end - def _scan(command, cursor, args, match: nil, count: nil, &block) + def _scan(command, cursor, args, match: nil, count: nil, type: nil, &block) # SSCAN/ZSCAN/HSCAN already prepend the key to +args+. args << cursor args << "MATCH" << match if match args << "COUNT" << count if count + args << "TYPE" << type if type synchronize do |client| client.call([command] + args, &block) @@ -2656,11 +2883,15 @@ class Redis # @example Retrieve a batch of keys matching a pattern # redis.scan(4, :match => "key:1?") # # => ["92", ["key:13", "key:18"]] + # @example Retrieve a batch of keys of a certain type + # redis.scan(92, :type => "zset") + # # => ["173", ["sortedset:14", "sortedset:78"]] # # @param [String, Integer] cursor the cursor of the iteration # @param [Hash] options # - `:match => String`: only return keys matching the pattern # - `:count => Integer`: return count keys at most per iteration + # - `:type => String`: return keys only of the given type # # @return [String, Array<String>] the next cursor and all found keys def scan(cursor, **options) @@ -2676,10 +2907,15 @@ class Redis # redis.scan_each(:match => "key:1?") {|key| puts key} # # => key:13 # # => key:18 + # @example Execute block for each key of a type + # redis.scan_each(:type => "hash") {|key| puts redis.type(key)} + # # => "hash" + # # => "hash" # # @param [Hash] options # - `:match => String`: only return keys matching the pattern # - `:count => Integer`: return count keys at most per iteration + # - `:type => String`: return keys only of the given type # # @return [Enumerator] an enumerator for all found keys def scan_each(**options, &block) @@ -3220,6 +3456,38 @@ class Redis synchronize { |client| client.call(args, &blk) } end + # Transfers ownership of pending stream entries that match the specified criteria. + # + # @example Claim next pending message stuck > 5 minutes and mark as retry + # redis.xautoclaim('mystream', 'mygroup', 'consumer1', 3600000, '0-0') + # @example Claim 50 next pending messages stuck > 5 minutes and mark as retry + # redis.xclaim('mystream', 'mygroup', 'consumer1', 3600000, '0-0', count: 50) + # @example Claim next pending message stuck > 5 minutes and don't mark as retry + # redis.xclaim('mystream', 'mygroup', 'consumer1', 3600000, '0-0', justid: true) + # @example Claim next pending message after this id stuck > 5 minutes and mark as retry + # redis.xautoclaim('mystream', 'mygroup', 'consumer1', 3600000, '1641321233-0') + # + # @param key [String] the stream key + # @param group [String] the consumer group name + # @param consumer [String] the consumer name + # @param min_idle_time [Integer] the number of milliseconds + # @param start [String] entry id to start scanning from or 0-0 for everything + # @param count [Integer] number of messages to claim (default 1) + # @param justid [Boolean] whether to fetch just an array of entry ids or not. + # Does not increment retry count when true + # + # @return [Hash{String => Hash}] the entries successfully claimed + # @return [Array<String>] the entry ids successfully claimed if justid option is `true` + def xautoclaim(key, group, consumer, min_idle_time, start, count: nil, justid: false) + args = [:xautoclaim, key, group, consumer, min_idle_time, start] + if count + args << 'COUNT' << count.to_s + end + args << 'JUSTID' if justid + blk = justid ? HashifyStreamAutoclaimJustId : HashifyStreamAutoclaim + synchronize { |client| client.call(args, &blk) } + end + # Fetches not acknowledging pending entries # # @example With key and group @@ -3426,10 +3694,24 @@ class Redis HashifyStreamEntries = lambda { |reply| reply.compact.map do |entry_id, values| - [entry_id, values.each_slice(2).to_h] + [entry_id, values&.each_slice(2)&.to_h] end } + HashifyStreamAutoclaim = lambda { |reply| + { + 'next' => reply[0], + 'entries' => reply[1].map { |entry| [entry[0], entry[1].each_slice(2).to_h] } + } + } + + HashifyStreamAutoclaimJustId = lambda { |reply| + { + 'next' => reply[0], + 'entries' => reply[1] + } + } + HashifyStreamPendings = lambda { |reply| { 'size' => reply[0], @@ -3529,6 +3811,21 @@ class Redis end end end + + def _normalize_move_wheres(where_source, where_destination) + where_source = where_source.to_s.upcase + where_destination = where_destination.to_s.upcase + + if where_source != "LEFT" && where_source != "RIGHT" + raise ArgumentError, "where_source must be 'LEFT' or 'RIGHT'" + end + + if where_destination != "LEFT" && where_destination != "RIGHT" + raise ArgumentError, "where_destination must be 'LEFT' or 'RIGHT'" + end + + [where_source, where_destination] + end end require_relative "redis/version" diff --git a/lib/redis/client.rb b/lib/redis/client.rb index f01512f95e7732aaa6daa7d8f7f24101bc58509f..69cbe4de24132d7bcf54c4515cea481b24782fc7 100644 --- a/lib/redis/client.rb +++ b/lib/redis/client.rb @@ -17,6 +17,7 @@ class Redis write_timeout: nil, connect_timeout: nil, timeout: 5.0, + username: nil, password: nil, db: 0, driver: nil, @@ -61,6 +62,10 @@ class Redis @options[:read_timeout] end + def username + @options[:username] + end + def password @options[:password] end @@ -110,7 +115,33 @@ class Redis # Don't try to reconnect when the connection is fresh with_reconnect(false) do establish_connection - call [:auth, password] if password + if password + if username + begin + call [:auth, username, password] + rescue CommandError => err # Likely on Redis < 6 + if err.message.match?(/ERR wrong number of arguments for \'auth\' command/) + call [:auth, password] + elsif err.message.match?(/WRONGPASS invalid username-password pair/) + begin + call [:auth, password] + rescue CommandError + raise err + end + ::Kernel.warn( + "[redis-rb] The Redis connection was configured with username #{username.inspect}, but" \ + " the provided password was for the default user. This will start failing in redis-rb 4.6." + ) + else + raise + end + end + else + call [:auth, password] + end + end + + call [:readonly] if @options[:readonly] call [:select, db] if db != 0 call [:client, :setname, @options[:id]] if @options[:id] @connector.check(self) @@ -131,7 +162,7 @@ class Redis reply = process([command]) { read } raise reply if reply.is_a?(CommandError) - if block_given? + if block_given? && reply != 'QUEUED' yield reply else reply @@ -434,7 +465,8 @@ class Redis defaults[:scheme] = uri.scheme defaults[:host] = uri.host if uri.host defaults[:port] = uri.port if uri.port - defaults[:password] = CGI.unescape(uri.password) if uri.password + defaults[:username] = CGI.unescape(uri.user) if uri.user && !uri.user.empty? + defaults[:password] = CGI.unescape(uri.password) if uri.password && !uri.password.empty? defaults[:db] = uri.path[1..-1].to_i if uri.path defaults[:role] = :master else @@ -510,7 +542,7 @@ class Redis require_relative "connection/#{driver}" rescue LoadError, NameError begin - require "connection/#{driver}" + require "redis/connection/#{driver}" rescue LoadError, NameError => error raise "Cannot load driver #{driver.inspect}: #{error.message}" end @@ -579,6 +611,7 @@ class Redis client = Client.new(@options.merge({ host: sentinel[:host] || sentinel["host"], port: sentinel[:port] || sentinel["port"], + username: sentinel[:username] || sentinel["username"], password: sentinel[:password] || sentinel["password"], reconnect_attempts: 0 })) diff --git a/lib/redis/cluster.rb b/lib/redis/cluster.rb index b8443192f8a11e6a9939481deb32945813586ee3..a3ffdc7ae93ac8d6046dac9b1722b5af15f8b741 100644 --- a/lib/redis/cluster.rb +++ b/lib/redis/cluster.rb @@ -78,11 +78,13 @@ class Redis end def call_pipeline(pipeline) - node_keys, command_keys = extract_keys_in_pipeline(pipeline) - raise CrossSlotPipeliningError, command_keys if node_keys.size > 1 + node_keys = pipeline.commands.map { |cmd| find_node_key(cmd, primary_only: true) }.compact.uniq + if node_keys.size > 1 + raise(CrossSlotPipeliningError, + pipeline.commands.map { |cmd| @command.extract_first_key(cmd) }.reject(&:empty?).uniq) + end - node = find_node(node_keys.first) - try_send(node, :call_pipeline, pipeline) + try_send(find_node(node_keys.first), :call_pipeline, pipeline) end def call_with_timeout(command, timeout, &block) @@ -128,7 +130,7 @@ class Redis def send_command(command, &block) cmd = command.first.to_s.downcase case cmd - when 'auth', 'bgrewriteaof', 'bgsave', 'quit', 'save' + when 'acl', 'auth', 'bgrewriteaof', 'bgsave', 'quit', 'save' @node.call_all(command, &block).first when 'flushall', 'flushdb' @node.call_master(command, &block).first @@ -253,14 +255,14 @@ class Redis find_node(node_key) end - def find_node_key(command) + def find_node_key(command, primary_only: false) key = @command.extract_first_key(command) return if key.empty? slot = KeySlotConverter.convert(key) return unless @slot.exists?(slot) - if @command.should_send_to_master?(command) + if @command.should_send_to_master?(command) || primary_only @slot.find_node_key_of_master(slot) else @slot.find_node_key_of_slave(slot) @@ -285,11 +287,5 @@ class Redis @node.map(&:disconnect) @node, @slot = fetch_cluster_info!(@option) end - - def extract_keys_in_pipeline(pipeline) - node_keys = pipeline.commands.map { |cmd| find_node_key(cmd) }.compact.uniq - command_keys = pipeline.commands.map { |cmd| @command.extract_first_key(cmd) }.reject(&:empty?) - [node_keys, command_keys] - end end end diff --git a/lib/redis/cluster/command_loader.rb b/lib/redis/cluster/command_loader.rb index 574212d356be4fb7ec88dc42ed41cc98c231d689..99673b6fba1e2998acb82d22905a09cc6f9ca492 100644 --- a/lib/redis/cluster/command_loader.rb +++ b/lib/redis/cluster/command_loader.rb @@ -10,22 +10,21 @@ class Redis module_function def load(nodes) - details = {} - nodes.each do |node| - details = fetch_command_details(node) - details.empty? ? next : break + begin + return fetch_command_details(node) + rescue CannotConnectError, ConnectionError, CommandError + next # can retry on another node + end end - details + raise CannotConnectError, 'Redis client could not connect to any cluster nodes' end def fetch_command_details(node) node.call(%i[command]).map do |reply| [reply[0], { arity: reply[1], flags: reply[2], first: reply[3], last: reply[4], step: reply[5] }] end.to_h - rescue CannotConnectError, ConnectionError, CommandError - {} # can retry on another node end private_class_method :fetch_command_details diff --git a/lib/redis/cluster/node.rb b/lib/redis/cluster/node.rb index 6040ea8eab39140e93f6aeef3424b970775cc202..f3be41461191263c7c8e7c7344b88ea7666ee579 100644 --- a/lib/redis/cluster/node.rb +++ b/lib/redis/cluster/node.rb @@ -76,8 +76,9 @@ class Redis clients = options.map do |node_key, option| next if replica_disabled? && slave?(node_key) + option = option.merge(readonly: true) if slave?(node_key) + client = Client.new(option) - client.call(%i[readonly]) if slave?(node_key) [node_key, client] end diff --git a/lib/redis/cluster/option.rb b/lib/redis/cluster/option.rb index 6641b30816bab66b01b12e72f18c9d28bada4afd..f0fdca2f749e805701078c6bf199165c47bd9e65 100644 --- a/lib/redis/cluster/option.rb +++ b/lib/redis/cluster/option.rb @@ -18,6 +18,7 @@ class Redis @node_opts = build_node_options(node_addrs) @replica = options.delete(:replica) == true add_common_node_option_if_needed(options, @node_opts, :scheme) + add_common_node_option_if_needed(options, @node_opts, :username) add_common_node_option_if_needed(options, @node_opts, :password) @options = options end @@ -63,7 +64,9 @@ class Redis raise InvalidClientOptionError, "Invalid uri scheme #{addr}" unless VALID_SCHEMES.include?(uri.scheme) db = uri.path.split('/')[1]&.to_i - { scheme: uri.scheme, password: uri.password, host: uri.host, port: uri.port, db: db }.reject { |_, v| v.nil? } + + { scheme: uri.scheme, username: uri.user, password: uri.password, host: uri.host, port: uri.port, db: db } + .reject { |_, v| v.nil? || v == '' } rescue URI::InvalidURIError => err raise InvalidClientOptionError, err.message end @@ -79,7 +82,7 @@ class Redis # Redis cluster node returns only host and port information. # So we should complement additional information such as: - # scheme, password and so on. + # scheme, username, password and so on. def add_common_node_option_if_needed(options, node_opts, key) return options if options[key].nil? && node_opts.first[key].nil? diff --git a/lib/redis/connection/command_helper.rb b/lib/redis/connection/command_helper.rb index 04b89113a6023c4e36319b9cfd2e07b0e7a68457..f14f90a875b4de9e653aa6bd739969d609353de4 100644 --- a/lib/redis/connection/command_helper.rb +++ b/lib/redis/connection/command_helper.rb @@ -12,11 +12,13 @@ class Redis if i.is_a? Array i.each do |j| j = j.to_s + j = j.encoding == Encoding::BINARY ? j : j.b command << "$#{j.bytesize}" command << j end else i = i.to_s + i = i.encoding == Encoding::BINARY ? i : i.b command << "$#{i.bytesize}" command << i end diff --git a/lib/redis/connection/ruby.rb b/lib/redis/connection/ruby.rb index 7ef5ca9fa6b535caad10bb78bd1a7532e8402e95..b623a1f3e37cbaaa15221c9ecb105f793c09197c 100644 --- a/lib/redis/connection/ruby.rb +++ b/lib/redis/connection/ruby.rb @@ -21,7 +21,7 @@ class Redis super(*args) @timeout = @write_timeout = nil - @buffer = "".dup + @buffer = "".b end def timeout=(timeout) @@ -35,7 +35,8 @@ class Redis def read(nbytes) result = @buffer.slice!(0, nbytes) - result << _read_from_socket(nbytes - result.bytesize) while result.bytesize < nbytes + buffer = String.new(capacity: nbytes, encoding: Encoding::ASCII_8BIT) + result << _read_from_socket(nbytes - result.bytesize, buffer) while result.bytesize < nbytes result end @@ -48,9 +49,9 @@ class Redis @buffer.slice!(0, crlf + CRLF.bytesize) end - def _read_from_socket(nbytes) + def _read_from_socket(nbytes, buffer = nil) loop do - case chunk = read_nonblock(nbytes, exception: false) + case chunk = read_nonblock(nbytes, buffer, exception: false) when :wait_readable unless wait_readable(@timeout) raise Redis::TimeoutError diff --git a/lib/redis/distributed.rb b/lib/redis/distributed.rb index 0ffaa2bd29c8d6b93951e9c65c796718332f5ab3..0ae33d9f1ffc7a0e222598e7d97ce561dd84a073 100644 --- a/lib/redis/distributed.rb +++ b/lib/redis/distributed.rb @@ -316,6 +316,16 @@ class Redis node_for(key).get(key) end + # Get the value of a key and delete it. + def getdel(key) + node_for(key).getdel(key) + end + + # Get the value of a key and sets its time to live based on options. + def getex(key, **options) + node_for(key).getex(key, **options) + end + # Get the values of all the given keys as an Array. def mget(*keys) mapped_mget(*keys).values_at(*keys) @@ -393,6 +403,21 @@ class Redis node_for(key).llen(key) end + # Remove the first/last element in a list, append/prepend it to another list and return it. + def lmove(source, destination, where_source, where_destination) + ensure_same_node(:lmove, [source, destination]) do |node| + node.lmove(source, destination, where_source, where_destination) + end + end + + # Remove the first/last element in a list and append/prepend it + # to another list and return it, or block until one is available. + def blmove(source, destination, where_source, where_destination, timeout: 0) + ensure_same_node(:lmove, [source, destination]) do |node| + node.blmove(source, destination, where_source, where_destination, timeout: timeout) + end + end + # Prepend one or more values to a list. def lpush(key, value) node_for(key).lpush(key, value) @@ -413,14 +438,14 @@ class Redis node_for(key).rpushx(key, value) end - # Remove and get the first element in a list. - def lpop(key) - node_for(key).lpop(key) + # Remove and get the first elements in a list. + def lpop(key, count = nil) + node_for(key).lpop(key, count) end - # Remove and get the last element in a list. - def rpop(key) - node_for(key).rpop(key) + # Remove and get the last elements in a list. + def rpop(key, count = nil) + node_for(key).rpop(key, count) end # Remove the last element in a list, append it to another list and return @@ -542,6 +567,11 @@ class Redis node_for(key).sismember(key, member) end + # Determine if multiple values are members of a set. + def smismember(key, *members) + node_for(key).smismember(key, *members) + end + # Get all the members in a set. def smembers(key) node_for(key).smembers(key) @@ -626,6 +656,16 @@ class Redis node_for(key).zscore(key, member) end + # Get one or more random members from a sorted set. + def zrandmember(key, count = nil, **options) + node_for(key).zrandmember(key, count, **options) + end + + # Get the scores associated with the given members in a sorted set. + def zmscore(key, *members) + node_for(key).zmscore(key, *members) + end + # Return a range of members in a sorted set, by index. def zrange(key, start, stop, **options) node_for(key).zrange(key, start, stop, **options) @@ -674,6 +714,13 @@ class Redis node_for(key).zcount(key, min, max) end + # Get the intersection of multiple sorted sets + def zinter(*keys, **options) + ensure_same_node(:zinter, keys) do |node| + node.zinter(*keys, **options) + end + end + # Intersect multiple sorted sets and store the resulting sorted set in a new # key. def zinterstore(destination, keys, **options) diff --git a/lib/redis/version.rb b/lib/redis/version.rb index 1a12c42dab02295aa7bec146c1b1679b7c0de5dd..abcaeb88dfd9507f689a1a975ef30fb1bb75a6d1 100644 --- a/lib/redis/version.rb +++ b/lib/redis/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true class Redis - VERSION = '4.2.5' + VERSION = '4.5.1' end diff --git a/makefile b/makefile index 307e75c30921f25c848278a51529df16d5a96035..c803066b17b56ebbbbb325611bf9eba2976432d6 100644 --- a/makefile +++ b/makefile @@ -1,4 +1,4 @@ -REDIS_BRANCH ?= 6.0 +REDIS_BRANCH ?= 6.2 TMP := tmp BUILD_DIR := ${TMP}/cache/redis-${REDIS_BRANCH} TARBALL := ${TMP}/redis-${REDIS_BRANCH}.tar.gz diff --git a/redis.gemspec b/redis.gemspec index b220c492da28c2c27b29cd9dabe269683832ec44..afdcaab8b053b302ee63272b3efb11f1e892f210 100644 --- a/redis.gemspec +++ b/redis.gemspec @@ -43,7 +43,7 @@ Gem::Specification.new do |s| s.files = Dir["CHANGELOG.md", "LICENSE", "README.md", "lib/**/*"] s.executables = `git ls-files -- exe/*`.split("\n").map { |f| File.basename(f) } - s.required_ruby_version = '>= 2.3.0' + s.required_ruby_version = '>= 2.4.0' s.add_development_dependency("em-synchrony") s.add_development_dependency("hiredis") diff --git a/test/blocking_commands_test.rb b/test/blocking_commands_test.rb index c32f11eef6e1a9247b150ac8fed8e38eb6215a56..5df8e6c5353d88b7e32646d5e66eac4fbdc850f6 100644 --- a/test/blocking_commands_test.rb +++ b/test/blocking_commands_test.rb @@ -21,6 +21,14 @@ class TestBlockingCommands < Minitest::Test end end + def test_blmove_disable_client_timeout + target_version "6.2" do + assert_takes_longer_than_client_timeout do |r| + assert_equal '0', r.blmove('foo', 'bar', 'LEFT', 'RIGHT') + end + end + end + def test_blpop_disable_client_timeout assert_takes_longer_than_client_timeout do |r| assert_equal %w[foo 0], r.blpop('foo') diff --git a/test/client_test.rb b/test/client_test.rb index 30f7e2cff916b882db04b4044a08adf784dfd7f9..7bcbb25cd9584a9e5a3c8a2075f20ecec073add6 100644 --- a/test/client_test.rb +++ b/test/client_test.rb @@ -73,4 +73,13 @@ class TestClient < Minitest::Test end assert_equal 'Error connecting to Redis on 127.0.0.5:999 (Errno::ECONNREFUSED)', error.message end + + def test_mixed_encoding + r.call("MSET", "fée", "\x00\xFF".b, "ã˜æ¡ˆ".encode(Encoding::SHIFT_JIS), "\t".encode(Encoding::ASCII)) + assert_equal "\x00\xFF", r.call("GET", "fée") + assert_equal "\t", r.call("GET", "ã˜æ¡ˆ".encode(Encoding::SHIFT_JIS)) + + r.call("SET", "\x00\xFF", "fée") + assert_equal "fée", r.call("GET", "\x00\xFF".b) + end end diff --git a/test/cluster_client_internals_test.rb b/test/cluster_client_internals_test.rb index 501d75806c09a71ce8dc5b037766de3fbed306bc..01c8acaebb0a319d359193a825dcd944fe9b84d3 100644 --- a/test/cluster_client_internals_test.rb +++ b/test/cluster_client_internals_test.rb @@ -74,4 +74,23 @@ class TestClusterClientInternals < Minitest::Test assert_equal expected, redis.connection end + + def test_acl_auth_success + target_version "6.0.0" do + with_acl do |username, password| + r = _new_client(cluster: DEFAULT_PORTS.map { |port| "redis://#{username}:#{password}@#{DEFAULT_HOST}:#{port}" }) + assert_equal('PONG', r.ping) + end + end + end + + def test_acl_auth_failure + target_version "6.0.0" do + with_acl do |username, _| + assert_raises(Redis::CannotConnectError) do + _new_client(cluster: DEFAULT_PORTS.map { |port| "redis://#{username}:wrongpassword@#{DEFAULT_HOST}:#{port}" }) + end + end + end + end end diff --git a/test/cluster_client_key_hash_tags_test.rb b/test/cluster_client_key_hash_tags_test.rb index 8ff4d301a3897ea5155b3ecc9ee8f4a15af72979..81e87165fb24169294fad282f6a9774551f02eec 100644 --- a/test/cluster_client_key_hash_tags_test.rb +++ b/test/cluster_client_key_hash_tags_test.rb @@ -6,8 +6,8 @@ require_relative 'helper' class TestClusterClientKeyHashTags < Minitest::Test include Helper::Cluster - def build_described_class - option = Redis::Cluster::Option.new(cluster: ['redis://127.0.0.1:7000']) + def build_described_class(urls = ['redis://127.0.0.1:7000']) + option = Redis::Cluster::Option.new(cluster: urls) node = Redis::Cluster::Node.new(option.per_node_key) details = Redis::Cluster::CommandLoader.load(node) Redis::Cluster::Command.new(details) @@ -85,4 +85,15 @@ class TestClusterClientKeyHashTags < Minitest::Test assert_equal false, described_class.should_send_to_slave?([:info]) end end + + def test_cannot_build_details_from_bad_urls + assert_raises(Redis::CannotConnectError) do + build_described_class(['redis://127.0.0.1:7006']) + end + end + + def test_builds_details_from_a_mix_of_good_and_bad_urls + described_class = build_described_class(['redis://127.0.0.1:7006', 'redis://127.0.0.1:7000']) + assert_equal 'dogs:1', described_class.extract_first_key(%w[get dogs:1]) + end end diff --git a/test/cluster_client_options_test.rb b/test/cluster_client_options_test.rb index 24d7df41f6a9b3f8a3b78184bbe35e70c61633c5..cc6e4d6e9b18bea7059fd1c6a0df9c85abd12788 100644 --- a/test/cluster_client_options_test.rb +++ b/test/cluster_client_options_test.rb @@ -20,11 +20,14 @@ class TestClusterClientOptions < Minitest::Test assert_equal false, option.use_replica? option = Redis::Cluster::Option.new(cluster: %w[rediss://johndoe:foobar@127.0.0.1:7000/1/namespace]) - assert_equal({ '127.0.0.1:7000' => { scheme: 'rediss', password: 'foobar', host: '127.0.0.1', port: 7000, db: 1 } }, option.per_node_key) + assert_equal({ '127.0.0.1:7000' => { scheme: 'rediss', username: 'johndoe', password: 'foobar', host: '127.0.0.1', port: 7000, db: 1 } }, option.per_node_key) option = Redis::Cluster::Option.new(cluster: %w[rediss://127.0.0.1:7000], scheme: 'redis') assert_equal({ '127.0.0.1:7000' => { scheme: 'rediss', host: '127.0.0.1', port: 7000 } }, option.per_node_key) + option = Redis::Cluster::Option.new(cluster: %w[redis://bazzap:@127.0.0.1:7000], username: 'foobar') + assert_equal({ '127.0.0.1:7000' => { scheme: 'redis', username: 'bazzap', host: '127.0.0.1', port: 7000 } }, option.per_node_key) + option = Redis::Cluster::Option.new(cluster: %w[redis://:bazzap@127.0.0.1:7000], password: 'foobar') assert_equal({ '127.0.0.1:7000' => { scheme: 'redis', password: 'bazzap', host: '127.0.0.1', port: 7000 } }, option.per_node_key) diff --git a/test/cluster_client_pipelining_test.rb b/test/cluster_client_pipelining_test.rb index 56f2c693cb2acffb35f36dcc0baf11e568eb70b1..85a54576f90359b7fff8f96aeaba4cd0b3f61081 100644 --- a/test/cluster_client_pipelining_test.rb +++ b/test/cluster_client_pipelining_test.rb @@ -56,4 +56,17 @@ class TestClusterClientPipelining < Minitest::Test end end end + + def test_pipelining_with_multiple_replicas + rc = build_another_client(replica: true) + rc.instance_variable_get(:@client).instance_variable_get(:@slot).instance_variable_get(:@map).each do |_, v| + v[:slaves] << v[:master] if v[:slaves].size < 2 # reproducing multiple replicas + end + + rc.pipelined do |r| + 10.times { r.get('key1') } + end + + rc.close + end end diff --git a/test/cluster_commands_on_connection_test.rb b/test/cluster_commands_on_connection_test.rb index b4129695a312bfbc4839a7fb02aff24f9a7c6ee0..24c093889888df88fe351cb33708b1b5844946d1 100644 --- a/test/cluster_commands_on_connection_test.rb +++ b/test/cluster_commands_on_connection_test.rb @@ -1,17 +1,13 @@ # frozen_string_literal: true require_relative 'helper' +require 'lint/authentication' # ruby -w -Itest test/cluster_commands_on_connection_test.rb # @see https://redis.io/commands#connection class TestClusterCommandsOnConnection < Minitest::Test include Helper::Cluster - - def test_auth - redis_cluster_mock(auth: ->(*_) { '+OK' }) do |redis| - assert_equal 'OK', redis.auth('my-password-123') - end - end + include Lint::Authentication def test_echo assert_equal 'hogehoge', redis.echo('hogehoge') @@ -37,4 +33,8 @@ class TestClusterCommandsOnConnection < Minitest::Test redis.swapdb(1, 2) end end + + def mock(*args, &block) + redis_cluster_mock(*args, &block) + end end diff --git a/test/cluster_commands_on_lists_test.rb b/test/cluster_commands_on_lists_test.rb index ea6e9704eeecd10f6f7625c9f213a17adb7fdde2..a483c8294dca4947c11d238a4b401dae105b327d 100644 --- a/test/cluster_commands_on_lists_test.rb +++ b/test/cluster_commands_on_lists_test.rb @@ -9,6 +9,12 @@ class TestClusterCommandsOnLists < Minitest::Test include Helper::Cluster include Lint::Lists + def test_lmove + target_version "6.2" do + assert_raises(Redis::CommandError) { super } + end + end + def test_rpoplpush assert_raises(Redis::CommandError) { super } end diff --git a/test/cluster_commands_on_server_test.rb b/test/cluster_commands_on_server_test.rb index b135de2cb6a8a3537bc8a1a8502ab15ea56776df..2f7faf0137246159c8a8d6c5a3d3eafa74576c6f 100644 --- a/test/cluster_commands_on_server_test.rb +++ b/test/cluster_commands_on_server_test.rb @@ -44,6 +44,7 @@ class TestClusterCommandsOnServer < Minitest::Test actual = a_client_info.keys.sort expected = %w[addr age cmd db events fd flags id idle multi name obl oll omem psub qbuf qbuf-free sub] expected << 'user' << 'argv-mem' << 'tot-mem' if version >= '6' + expected << 'laddr' << 'redir' if version >= '6.2' assert_equal expected.sort, actual.sort end @@ -79,10 +80,16 @@ class TestClusterCommandsOnServer < Minitest::Test end def test_command_info + eval_command_flags = if version >= '6.2' + %w[noscript skip_monitor may_replicate movablekeys] + else + %w[noscript movablekeys] + end + expected = [ ['get', 2, %w[readonly fast], 1, 1, 1], ['set', -3, %w[write denyoom], 1, 1, 1], - ['eval', -3, %w[noscript movablekeys], 0, 0, 0] + ['eval', -3, eval_command_flags, 0, 0, 0] ] if version >= '6' expected[0] << ["@read", "@string", "@fast"] diff --git a/test/cluster_commands_on_sorted_sets_test.rb b/test/cluster_commands_on_sorted_sets_test.rb index 3c37b429af727b67dbe91de515716eca79a953ef..87da66ee879cb15e8edf908e099742f3eab2252b 100644 --- a/test/cluster_commands_on_sorted_sets_test.rb +++ b/test/cluster_commands_on_sorted_sets_test.rb @@ -9,6 +9,18 @@ class TestClusterCommandsOnSortedSets < Minitest::Test include Helper::Cluster include Lint::SortedSets + def test_zinter + assert_raises(Redis::CommandError) { super } + end + + def test_zinter_with_aggregate + assert_raises(Redis::CommandError) { super } + end + + def test_zinter_with_weights + assert_raises(Redis::CommandError) { super } + end + def test_zinterstore assert_raises(Redis::CommandError) { super } end diff --git a/test/commands_on_value_types_test.rb b/test/commands_on_value_types_test.rb index fddc5f2ac1922d749c2c460546f7890c1bd71b86..60db89da1febca013337e437b0c721784bf592c1 100644 --- a/test/commands_on_value_types_test.rb +++ b/test/commands_on_value_types_test.rb @@ -14,6 +14,8 @@ class TestCommandsOnValueTypes < Minitest::Test assert_equal ["bar", "baz", "foo"], r.keys("*").sort + assert_equal 0, r.del("") + assert_equal 1, r.del("foo") assert_equal ["bar", "baz"], r.keys("*").sort @@ -30,6 +32,8 @@ class TestCommandsOnValueTypes < Minitest::Test assert_equal ["bar", "baz", "foo"], r.keys("*").sort + assert_equal 0, r.del([]) + assert_equal 1, r.del(["foo"]) assert_equal ["bar", "baz"], r.keys("*").sort diff --git a/test/connection_handling_test.rb b/test/connection_handling_test.rb index 7949a229721b9b27a745a0b415b5cbaee2dd4a71..1e750f09922ca0c77de2a9e3413745e76ff8d260 100644 --- a/test/connection_handling_test.rb +++ b/test/connection_handling_test.rb @@ -1,20 +1,11 @@ # frozen_string_literal: true require_relative "helper" +require 'lint/authentication' class TestConnectionHandling < Minitest::Test include Helper::Client - - def test_auth - commands = { - auth: ->(password) { @auth = password; "+OK" }, - get: ->(_key) { @auth == "secret" ? "$3\r\nbar" : "$-1" } - } - - redis_mock(commands, password: "secret") do |redis| - assert_equal "bar", redis.get("foo") - end - end + include Lint::Authentication def test_id commands = { diff --git a/test/connection_test.rb b/test/connection_test.rb index 933bd6155ddd69bf0d6d69b2bc82d144cd0afc83..5c8bf600926d0480cc27db355d95bcaec1a11307 100644 --- a/test/connection_test.rb +++ b/test/connection_test.rb @@ -9,6 +9,34 @@ class TestConnection < Minitest::Test assert_equal "#<Redis client v#{Redis::VERSION} for redis://127.0.0.1:#{PORT}/15>", r.inspect end + def test_connection_with_user_and_password + target_version "6.0" do + with_acl do |username, password| + redis = Redis.new(OPTIONS.merge(username: username, password: password)) + assert_equal "PONG", redis.ping + end + end + end + + def test_connection_with_default_user_and_password + target_version "6.0" do + with_default_user_password do |_username, password| + redis = Redis.new(OPTIONS.merge(password: password)) + assert_equal "PONG", redis.ping + end + end + end + + def test_connection_with_wrong_user_and_password + target_version "6.0" do + with_default_user_password do |_username, password| + Kernel.expects(:warn).once + redis = Redis.new(OPTIONS.merge(username: "does-not-exist", password: password)) + assert_equal "PONG", redis.ping + end + end + end + def test_connection_information assert_equal "127.0.0.1", r.connection.fetch(:host) assert_equal 6381, r.connection.fetch(:port) diff --git a/test/distributed_blocking_commands_test.rb b/test/distributed_blocking_commands_test.rb index efa86918f5e26794a1ae757cb9cb939a5bb2fb24..a2c0d1dc8957a7d39965b9cc1a963fd32a8fb68c 100644 --- a/test/distributed_blocking_commands_test.rb +++ b/test/distributed_blocking_commands_test.rb @@ -7,6 +7,14 @@ class TestDistributedBlockingCommands < Minitest::Test include Helper::Distributed include Lint::BlockingCommands + def test_blmove_raises + target_version "6.2" do + assert_raises(Redis::Distributed::CannotDistribute) do + r.blmove('foo', 'bar', 'LEFT', 'RIGHT') + end + end + end + def test_blpop_raises assert_raises(Redis::Distributed::CannotDistribute) do r.blpop(%w[foo bar]) diff --git a/test/distributed_commands_on_lists_test.rb b/test/distributed_commands_on_lists_test.rb index c9039a059c5413fe1d91d505fa2bf8b44c9f7556..4c372340042d570fdbe3ad0caeb0f8f0c6c157c9 100644 --- a/test/distributed_commands_on_lists_test.rb +++ b/test/distributed_commands_on_lists_test.rb @@ -7,6 +7,14 @@ class TestDistributedCommandsOnLists < Minitest::Test include Helper::Distributed include Lint::Lists + def test_lmove + target_version "6.2" do + assert_raises Redis::Distributed::CannotDistribute do + r.lmove('foo', 'bar', 'LEFT', 'RIGHT') + end + end + end + def test_rpoplpush assert_raises Redis::Distributed::CannotDistribute do r.rpoplpush('foo', 'bar') diff --git a/test/distributed_commands_on_sorted_sets_test.rb b/test/distributed_commands_on_sorted_sets_test.rb index 2e628364c8d1f9496b2b1a61e66a57227e4144a3..cd220bcf02769b9aab096d869f8e1f2afa0ad3b9 100644 --- a/test/distributed_commands_on_sorted_sets_test.rb +++ b/test/distributed_commands_on_sorted_sets_test.rb @@ -7,6 +7,18 @@ class TestDistributedCommandsOnSortedSets < Minitest::Test include Helper::Distributed include Lint::SortedSets + def test_zinter + assert_raises(Redis::Distributed::CannotDistribute) { super } + end + + def test_zinter_with_aggregate + assert_raises(Redis::Distributed::CannotDistribute) { super } + end + + def test_zinter_with_weights + assert_raises(Redis::Distributed::CannotDistribute) { super } + end + def test_zinterstore assert_raises(Redis::Distributed::CannotDistribute) { super } end diff --git a/test/distributed_commands_requiring_clustering_test.rb b/test/distributed_commands_requiring_clustering_test.rb index 35bd7988fbb7303b7560752c6b03863a509e7ba8..7ffbe532d18188447add80325d213efa6b5cd431 100644 --- a/test/distributed_commands_requiring_clustering_test.rb +++ b/test/distributed_commands_requiring_clustering_test.rb @@ -23,6 +23,19 @@ class TestDistributedCommandsRequiringClustering < Minitest::Test assert_equal "s2", r.get("{qux}bar") end + def test_lmove + target_version "6.2" do + r.rpush("{qux}foo", "s1") + r.rpush("{qux}foo", "s2") + r.rpush("{qux}bar", "s3") + r.rpush("{qux}bar", "s4") + + assert_equal "s1", r.lmove("{qux}foo", "{qux}bar", "LEFT", "RIGHT") + assert_equal ["s2"], r.lrange("{qux}foo", 0, -1) + assert_equal ["s3", "s4", "s1"], r.lrange("{qux}bar", 0, -1) + end + end + def test_brpoplpush r.rpush "{qux}foo", "s1" r.rpush "{qux}foo", "s2" diff --git a/test/helper.rb b/test/helper.rb index 81be94b083db16c22e0097882519ebd3545b7c11..0a98354db7224cb8e8206417702c93c96eb47c2e 100644 --- a/test/helper.rb +++ b/test/helper.rb @@ -165,6 +165,26 @@ module Helper def version Version.new(redis.info['redis_version']) end + + def with_acl + admin = _new_client + admin.acl('SETUSER', 'johndoe', 'on', + '+ping', '+select', '+command', '+cluster|slots', '+cluster|nodes', + '>mysecret') + yield('johndoe', 'mysecret') + ensure + admin.acl('DELUSER', 'johndoe') + admin.close + end + + def with_default_user_password + admin = _new_client + admin.acl('SETUSER', 'default', '>mysecret') + yield('default', 'mysecret') + ensure + admin.acl('SETUSER', 'default', 'nopass') + admin.close + end end module Client diff --git a/test/lint/authentication.rb b/test/lint/authentication.rb new file mode 100644 index 0000000000000000000000000000000000000000..6dad12ef20e1f2a90c2c97710d12619faf1393c7 --- /dev/null +++ b/test/lint/authentication.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Lint + module Authentication + def test_auth_with_password + mock(auth: ->(*_) { '+OK' }) do |r| + assert_equal 'OK', r.auth('mysecret') + end + + mock(auth: ->(*_) { '-ERR some error' }) do |r| + assert_raises(Redis::BaseError) { r.auth('mysecret') } + end + end + + def test_auth_for_acl + target_version "6.0.0" do + with_acl do |username, password| + assert_raises(Redis::BaseError) { redis.auth(username, 'wrongpassword') } + assert_equal 'OK', redis.auth(username, password) + assert_equal 'PONG', redis.ping + assert_raises(Redis::BaseError) { redis.echo('foo') } + end + end + end + + def mock(*args, &block) + redis_mock(*args, &block) + end + end +end diff --git a/test/lint/blocking_commands.rb b/test/lint/blocking_commands.rb index 11bbe0bf29b64ef6a062cb437d4529835a346e92..3500b53d05d507c5e6e7e62e8bf0f6556984cbd0 100644 --- a/test/lint/blocking_commands.rb +++ b/test/lint/blocking_commands.rb @@ -32,6 +32,10 @@ module Lint def build_mock_commands(options = {}) { + blmove: lambda do |*args| + sleep options[:delay] if options.key?(:delay) + to_protocol(args.last) + end, blpop: lambda do |*args| sleep options[:delay] if options.key?(:delay) to_protocol([args.first, args.last]) @@ -55,6 +59,23 @@ module Lint } end + def test_blmove + target_version "6.2" do + assert_equal 's1', r.blmove('{zap}foo', '{zap}bar', 'LEFT', 'RIGHT') + assert_equal ['s2'], r.lrange('{zap}foo', 0, -1) + assert_equal ['s1', 's2', 's1'], r.lrange('{zap}bar', 0, -1) + end + end + + def test_blmove_timeout + target_version "6.2" do + mock do |r| + assert_equal '0', r.blmove('{zap}foo', '{zap}bar', 'LEFT', 'RIGHT') + assert_equal LOW_TIMEOUT.to_s, r.blmove('{zap}foo', '{zap}bar', 'LEFT', 'RIGHT', timeout: LOW_TIMEOUT) + end + end + end + def test_blpop assert_equal ['{zap}foo', 's1'], r.blpop('{zap}foo') assert_equal ['{zap}foo', 's2'], r.blpop(['{zap}foo']) @@ -166,6 +187,16 @@ module Lint end driver(:ruby, :hiredis) do + def test_blmove_socket_timeout + target_version "6.2" do + mock(delay: LOW_TIMEOUT * 5) do |r| + assert_raises(Redis::TimeoutError) do + r.blmove('{zap}foo', '{zap}bar', 'LEFT', 'RIGHT', timeout: LOW_TIMEOUT) + end + end + end + end + def test_blpop_socket_timeout mock(delay: LOW_TIMEOUT * 5) do |r| assert_raises(Redis::TimeoutError) do diff --git a/test/lint/lists.rb b/test/lint/lists.rb index b0c5a2570d1e842d9d435bf048a2d3dbd4c17b0f..534ab8b8e971b31d821f3ea775607552aa0bb8ad 100644 --- a/test/lint/lists.rb +++ b/test/lint/lists.rb @@ -2,6 +2,33 @@ module Lint module Lists + def test_lmove + target_version "6.2" do + r.lpush("foo", "s1") + r.lpush("foo", "s2") # foo = [s2, s1] + r.lpush("bar", "s3") + r.lpush("bar", "s4") # bar = [s4, s3] + + assert_nil r.lmove("nonexistent", "foo", "LEFT", "LEFT") + + assert_equal "s2", r.lmove("foo", "foo", "LEFT", "RIGHT") # foo = [s1, s2] + assert_equal "s1", r.lmove("foo", "foo", "LEFT", "LEFT") # foo = [s1, s2] + + assert_equal "s1", r.lmove("foo", "bar", "LEFT", "RIGHT") # foo = [s2], bar = [s4, s3, s1] + assert_equal ["s2"], r.lrange("foo", 0, -1) + assert_equal ["s4", "s3", "s1"], r.lrange("bar", 0, -1) + + assert_equal "s2", r.lmove("foo", "bar", "LEFT", "LEFT") # foo = [], bar = [s2, s4, s3, s1] + assert_nil r.lmove("foo", "bar", "LEFT", "LEFT") # foo = [], bar = [s2, s4, s3, s1] + assert_equal ["s2", "s4", "s3", "s1"], r.lrange("bar", 0, -1) + + error = assert_raises(ArgumentError) do + r.lmove("foo", "bar", "LEFT", "MIDDLE") + end + assert_equal "where_destination must be 'LEFT' or 'RIGHT'", error.message + end + end + def test_lpush r.lpush "foo", "s1" r.lpush "foo", "s2" @@ -119,6 +146,17 @@ module Lint assert_equal 1, r.llen("foo") end + def test_lpop_count + target_version("6.2") do + r.rpush "foo", "s1" + r.rpush "foo", "s2" + + assert_equal 2, r.llen("foo") + assert_equal ["s1", "s2"], r.lpop("foo", 2) + assert_equal 0, r.llen("foo") + end + end + def test_rpop r.rpush "foo", "s1" r.rpush "foo", "s2" @@ -128,6 +166,17 @@ module Lint assert_equal 1, r.llen("foo") end + def test_rpop_count + target_version("6.2") do + r.rpush "foo", "s1" + r.rpush "foo", "s2" + + assert_equal 2, r.llen("foo") + assert_equal ["s2", "s1"], r.rpop("foo", 2) + assert_equal 0, r.llen("foo") + end + end + def test_linsert r.rpush "foo", "s1" r.rpush "foo", "s3" diff --git a/test/lint/sets.rb b/test/lint/sets.rb index 56c167de2f771646b8a73c5fc249b216b7d3cf3e..c799ae6789c36894901004f54179cc487deaeeca 100644 --- a/test/lint/sets.rb +++ b/test/lint/sets.rb @@ -88,6 +88,18 @@ module Lint assert_equal false, r.sismember("foo", "s2") end + def test_smismember + target_version("6.2") do + assert_equal [false], r.smismember("foo", "s1") + + r.sadd "foo", "s1" + assert_equal [true], r.smismember("foo", "s1") + + r.sadd "foo", "s3" + assert_equal [true, false, true], r.smismember("foo", "s1", "s2", "s3") + end + end + def test_smembers assert_equal [], r.smembers("foo") diff --git a/test/lint/sorted_sets.rb b/test/lint/sorted_sets.rb index 40a6f02eab194c154263b16c9ad4ff98a1cec7b4..e1863d80c8586f5520c05a99d85d27171dcb3678 100644 --- a/test/lint/sorted_sets.rb +++ b/test/lint/sorted_sets.rb @@ -45,6 +45,35 @@ module Lint # Incompatible options combination assert_raises(Redis::CommandError) { r.zadd("foo", 1, "s1", xx: true, nx: true) } end + + target_version "6.2" do + # LT option + r.zadd("foo", 2, "s1") + + r.zadd("foo", 3, "s1", lt: true) + assert_equal 2.0, r.zscore("foo", "s1") + + r.zadd("foo", 1, "s1", lt: true) + assert_equal 1.0, r.zscore("foo", "s1") + + assert_equal true, r.zadd("foo", 3, "s2", lt: true) # adds new member + r.del "foo" + + # GT option + r.zadd("foo", 2, "s1") + + r.zadd("foo", 1, "s1", gt: true) + assert_equal 2.0, r.zscore("foo", "s1") + + r.zadd("foo", 3, "s1", gt: true) + assert_equal 3.0, r.zscore("foo", "s1") + + assert_equal true, r.zadd("foo", 1, "s2", gt: true) # adds new member + r.del "foo" + + # Incompatible options combination + assert_raises(Redis::CommandError) { r.zadd("foo", 1, "s1", nx: true, gt: true) } + end end def test_variadic_zadd @@ -109,6 +138,28 @@ module Lint # Incompatible options combination assert_raises(Redis::CommandError) { r.zadd("foo", [1, "s1"], xx: true, nx: true) } end + + target_version "6.2" do + # LT option + r.zadd("foo", 2, "s1") + + assert_equal 1, r.zadd("foo", [3, "s1", 2, "s2"], lt: true, ch: true) + assert_equal 2.0, r.zscore("foo", "s1") + + assert_equal 1, r.zadd("foo", [1, "s1"], lt: true, ch: true) + + r.del "foo" + + # GT option + r.zadd("foo", 2, "s1") + + assert_equal 1, r.zadd("foo", [1, "s1", 2, "s2"], gt: true, ch: true) + assert_equal 2.0, r.zscore("foo", "s1") + + assert_equal 1, r.zadd("foo", [3, "s1"], gt: true, ch: true) + + r.del "foo" + end end def test_zrem @@ -293,6 +344,48 @@ module Lint assert_equal(+Float::INFINITY, r.zscore("bar", "s2")) end + def test_zmscore + target_version("6.2") do + r.zadd "foo", 1, "s1" + + assert_equal [1.0], r.zmscore("foo", "s1") + assert_equal [nil], r.zmscore("foo", "s2") + + r.zadd "foo", "-inf", "s2" + r.zadd "foo", "+inf", "s3" + assert_equal [1.0, nil], r.zmscore("foo", "s1", "s4") + assert_equal [-Float::INFINITY, +Float::INFINITY], r.zmscore("foo", "s2", "s3") + end + end + + def test_zrandmember + target_version("6.2") do + assert_nil r.zrandmember("foo") + + r.zadd "foo", 1.0, "s1" + r.zrem "foo", "s1" + assert_nil r.zrandmember("foo") + assert_equal [], r.zrandmember("foo", 1) + + r.zadd "foo", 1.0, "s1" + r.zadd "foo", 2.0, "s2" + r.zadd "foo", 3.0, "s3" + + 3.times do + assert ["s1", "s2", "s3"].include?(r.zrandmember("foo")) + end + + assert_equal 2, r.zrandmember("foo", 2).size + assert_equal 3, r.zrandmember("foo", 4).size + assert_equal 5, r.zrandmember("foo", -5).size + + r.zrandmember("foo", 2, with_scores: true).each do |(member, score)| + assert ["s1", "s2", "s3"].include?(member) + assert_instance_of Float, score + end + end + end + def test_zremrangebyrank r.zadd "foo", 10, "s1" r.zadd "foo", 20, "s2" @@ -432,6 +525,55 @@ module Lint assert_equal 5, r.zunionstore('{1}baz', %w[{1}foo {1}bar]) end + def test_zinter + target_version("6.2") do + r.zadd 'foo', 1, 's1' + r.zadd 'bar', 2, 's1' + r.zadd 'foo', 3, 's3' + r.zadd 'bar', 4, 's4' + + assert_equal ['s1'], r.zinter('foo', 'bar') + assert_equal [['s1', 3.0]], r.zinter('foo', 'bar', with_scores: true) + end + end + + def test_zinter_with_weights + target_version("6.2") do + r.zadd 'foo', 1, 's1' + r.zadd 'foo', 2, 's2' + r.zadd 'foo', 3, 's3' + r.zadd 'bar', 20, 's2' + r.zadd 'bar', 30, 's3' + r.zadd 'bar', 40, 's4' + + assert_equal %w[s2 s3], r.zinter('foo', 'bar') + assert_equal [['s2', 22.0], ['s3', 33.0]], r.zinter('foo', 'bar', with_scores: true) + + assert_equal %w[s2 s3], r.zinter('foo', 'bar', weights: [10, 1]) + assert_equal [['s2', 40.0], ['s3', 60.0]], r.zinter('foo', 'bar', weights: [10, 1], with_scores: true) + end + end + + def test_zinter_with_aggregate + target_version("6.2") do + r.zadd 'foo', 1, 's1' + r.zadd 'foo', 2, 's2' + r.zadd 'foo', 3, 's3' + r.zadd 'bar', 20, 's2' + r.zadd 'bar', 30, 's3' + r.zadd 'bar', 40, 's4' + + assert_equal %w[s2 s3], r.zinter('foo', 'bar') + assert_equal [['s2', 22.0], ['s3', 33.0]], r.zinter('foo', 'bar', with_scores: true) + + assert_equal %w[s2 s3], r.zinter('foo', 'bar', aggregate: :min) + assert_equal [['s2', 2.0], ['s3', 3.0]], r.zinter('foo', 'bar', aggregate: :min, with_scores: true) + + assert_equal %w[s2 s3], r.zinter('foo', 'bar', aggregate: :max) + assert_equal [['s2', 20.0], ['s3', 30.0]], r.zinter('foo', 'bar', aggregate: :max, with_scores: true) + end + end + def test_zinterstore r.zadd 'foo', 1, 's1' r.zadd 'bar', 2, 's1' diff --git a/test/lint/streams.rb b/test/lint/streams.rb index 6ac52c0e077385df767566fdff7f51d3d52a8716..d7060fbb0e2c0e81b5066c30a11d3f420b6ae784 100644 --- a/test/lint/streams.rb +++ b/test/lint/streams.rb @@ -3,6 +3,7 @@ module Lint module Streams MIN_REDIS_VERSION = '4.9.0' + MIN_REDIS_VERSION_XAUTOCLAIM = '6.2.0' ENTRY_ID_FORMAT = /\d+-\d+/.freeze def setup @@ -116,10 +117,17 @@ module Lint end def test_xtrim_with_invalid_arguments - assert_equal 0, redis.xtrim('', '') - assert_equal 0, redis.xtrim(nil, nil) - assert_equal 0, redis.xtrim('s1', 0) - assert_equal 0, redis.xtrim('s1', -1, approximate: true) + if version >= '6.2' + assert_raises(Redis::CommandError) { redis.xtrim('', '') } + assert_raises(Redis::CommandError) { redis.xtrim(nil, nil) } + assert_equal 0, redis.xtrim('s1', 0) + assert_raises(Redis::CommandError) { redis.xtrim('s1', -1, approximate: true) } + else + assert_equal 0, redis.xtrim('', '') + assert_equal 0, redis.xtrim(nil, nil) + assert_equal 0, redis.xtrim('s1', 0) + assert_equal 0, redis.xtrim('s1', -1, approximate: true) + end end def test_xdel_with_splatted_entry_ids @@ -483,6 +491,17 @@ module Lint assert_raises(Redis::CommandError) { redis.xreadgroup('g1', 'c1', 's1', %w[> >]) } end + def test_xreadgroup_a_trimmed_entry + redis.xgroup(:create, 'k1', 'g1', '0', mkstream: true) + entry_id = redis.xadd('k1', { value: 'v1' }) + + assert_equal({ 'k1' => [[entry_id, { 'value' => 'v1' }]] }, redis.xreadgroup('g1', 'c1', 'k1', '>')) + assert_equal({ 'k1' => [[entry_id, { 'value' => 'v1' }]] }, redis.xreadgroup('g1', 'c1', 'k1', '0')) + redis.xtrim('k1', 0) + + assert_equal({ 'k1' => [[entry_id, nil]] }, redis.xreadgroup('g1', 'c1', 'k1', '0')) + end + def test_xack_with_a_entry_id redis.xadd('s1', { f: 'v1' }, id: '0-1') redis.xgroup(:create, 's1', 'g1', '$') @@ -626,6 +645,72 @@ module Lint assert_raises(Redis::CommandError) { redis.xclaim('', '', '', '', '') } end + def test_xautoclaim + omit_version(MIN_REDIS_VERSION_XAUTOCLAIM) + + redis.xadd('s1', { f: 'v1' }, id: '0-1') + redis.xgroup(:create, 's1', 'g1', '$') + redis.xadd('s1', { f: 'v2' }, id: '0-2') + redis.xadd('s1', { f: 'v3' }, id: '0-3') + redis.xreadgroup('g1', 'c1', 's1', '>') + sleep 0.01 + + actual = redis.xautoclaim('s1', 'g1', 'c2', 10, '0-0') + + assert_equal '0-0', actual['next'] + assert_equal %w(0-2 0-3), actual['entries'].map(&:first) + assert_equal(%w(v2 v3), actual['entries'].map { |i| i.last['f'] }) + end + + def test_xautoclaim_with_justid_option + omit_version(MIN_REDIS_VERSION_XAUTOCLAIM) + + redis.xadd('s1', { f: 'v1' }, id: '0-1') + redis.xgroup(:create, 's1', 'g1', '$') + redis.xadd('s1', { f: 'v2' }, id: '0-2') + redis.xadd('s1', { f: 'v3' }, id: '0-3') + redis.xreadgroup('g1', 'c1', 's1', '>') + sleep 0.01 + + actual = redis.xautoclaim('s1', 'g1', 'c2', 10, '0-0', justid: true) + + assert_equal '0-0', actual['next'] + assert_equal %w(0-2 0-3), actual['entries'] + end + + def test_xautoclaim_with_count_option + omit_version(MIN_REDIS_VERSION_XAUTOCLAIM) + + redis.xadd('s1', { f: 'v1' }, id: '0-1') + redis.xgroup(:create, 's1', 'g1', '$') + redis.xadd('s1', { f: 'v2' }, id: '0-2') + redis.xadd('s1', { f: 'v3' }, id: '0-3') + redis.xreadgroup('g1', 'c1', 's1', '>') + sleep 0.01 + + actual = redis.xautoclaim('s1', 'g1', 'c2', 10, '0-0', count: 1) + + assert_equal '0-3', actual['next'] + assert_equal %w(0-2), actual['entries'].map(&:first) + assert_equal(%w(v2), actual['entries'].map { |i| i.last['f'] }) + end + + def test_xautoclaim_with_larger_interval + omit_version(MIN_REDIS_VERSION_XAUTOCLAIM) + + redis.xadd('s1', { f: 'v1' }, id: '0-1') + redis.xgroup(:create, 's1', 'g1', '$') + redis.xadd('s1', { f: 'v2' }, id: '0-2') + redis.xadd('s1', { f: 'v3' }, id: '0-3') + redis.xreadgroup('g1', 'c1', 's1', '>') + sleep 0.01 + + actual = redis.xautoclaim('s1', 'g1', 'c2', 36_000, '0-0') + + assert_equal '0-0', actual['next'] + assert_equal [], actual['entries'] + end + def test_xpending redis.xadd('s1', { f: 'v1' }, id: '0-1') redis.xgroup(:create, 's1', 'g1', '$') diff --git a/test/lint/strings.rb b/test/lint/strings.rb index e9ecf58c53d7076b7933fa06d7fe74a549692aa3..78a483aa9424b85faffe5ae160781c740ea3cc84 100644 --- a/test/lint/strings.rb +++ b/test/lint/strings.rb @@ -51,6 +51,20 @@ module Lint end end + def test_set_with_exat + target_version "6.2" do + r.set("foo", "bar", exat: Time.now.to_i + 2) + assert_in_range 0..2, r.ttl("foo") + end + end + + def test_set_with_pxat + target_version "6.2" do + r.set("foo", "bar", pxat: (1000 * Time.now.to_i) + 2000) + assert_in_range 0..2, r.ttl("foo") + end + end + def test_set_with_nx target_version "2.6.12" do r.set("foo", "qux", nx: true) @@ -83,6 +97,18 @@ module Lint end end + def test_set_with_get + target_version "6.2" do + r.set("foo", "qux") + + assert_equal "qux", r.set("foo", "bar", get: true) + assert_equal "bar", r.get("foo") + + assert_nil r.set("baz", "bar", get: true) + assert_equal "bar", r.get("baz") + end + end + def test_setex assert r.setex("foo", 1, "bar") assert_equal "bar", r.get("foo") @@ -115,6 +141,22 @@ module Lint end end + def test_getex + target_version "6.2" do + assert r.setex("foo", 1000, "bar") + assert_equal "bar", r.getex("foo", persist: true) + assert_equal(-1, r.ttl("foo")) + end + end + + def test_getdel + target_version "6.2" do + assert r.set("foo", "bar") + assert_equal "bar", r.getdel("foo") + assert_nil r.get("foo") + end + end + def test_getset r.set("foo", "bar") diff --git a/test/lint/value_types.rb b/test/lint/value_types.rb index c2ca2552f9866b17adffc14d6942abbcf8615d2f..e23715b7cfe8a7181965b0fb7881f8c8a96259bb 100644 --- a/test/lint/value_types.rb +++ b/test/lint/value_types.rb @@ -3,21 +3,21 @@ module Lint module ValueTypes def test_exists - assert_equal false, r.exists("foo") + assert_equal 0, r.exists("foo") r.set("foo", "s1") - assert_equal true, r.exists("foo") + assert_equal 1, r.exists("foo") end def test_exists_integer previous_exists_returns_integer = Redis.exists_returns_integer - Redis.exists_returns_integer = true - assert_equal 0, r.exists("foo") + Redis.exists_returns_integer = false + assert_equal false, r.exists("foo") r.set("foo", "s1") - assert_equal 1, r.exists("foo") + assert_equal true, r.exists("foo") ensure Redis.exists_returns_integer = previous_exists_returns_integer end diff --git a/test/scanning_test.rb b/test/scanning_test.rb index e21d1bcf00248d1e74cfa6f072ce39cd4b9ce68a..ee51ec6ac0878b41cfc88d192e6451ae11619487 100644 --- a/test/scanning_test.rb +++ b/test/scanning_test.rb @@ -53,6 +53,25 @@ class TestScanning < Minitest::Test end end + def test_scan_type + target_version "6.0.0" do + r.debug :populate, 1000 + r.zadd("foo", [1, "s1", 2, "s2", 3, "s3"]) + r.zadd("bar", [6, "s1", 5, "s2", 4, "s3"]) + r.hset("baz", "k1", "v1") + + cursor = 0 + all_keys = [] + loop do + cursor, keys = r.scan cursor, type: "zset" + all_keys += keys + break if cursor == "0" + end + + assert_equal 2, all_keys.uniq.size + end + end + def test_scan_each_enumerator target_version "2.7.105" do r.debug :populate, 1000 @@ -78,6 +97,20 @@ class TestScanning < Minitest::Test end end + def test_scan_each_enumerator_type + target_version "6.0.0" do + r.debug :populate, 1000 + r.zadd("key:zset", [1, "s1", 2, "s2", 3, "s3"]) + r.hset("key:hash:1", "k1", "v1") + r.hset("key:hash:2", "k2", "v2") + + keys_from_scan = r.scan_each(type: "hash").to_a.uniq + all_keys = r.keys "key:hash:*" + + assert all_keys.sort == keys_from_scan.sort + end + end + def test_scan_each_block target_version "2.7.105" do r.debug :populate, 100 diff --git a/test/sentinel_test.rb b/test/sentinel_test.rb index 5122235af11d3d156c0dc9897e8b788afa96a5d4..d913feed1e159a5ae7e777c1e58e6fe703f8e540 100644 --- a/test/sentinel_test.rb +++ b/test/sentinel_test.rb @@ -257,6 +257,49 @@ class SentinelTest < Minitest::Test assert_equal [%w[auth bar], %w[role]], commands[:m1] end + def test_authentication_with_acl + commands = { s1: [], m1: [] } + + sentinel = lambda do |port| + { + auth: lambda do |user, pass| + commands[:s1] << ['auth', user, pass] + '+OK' + end, + select: lambda do |db| + commands[:s1] << ['select', db] + '-ERR unknown command `select`' + end, + sentinel: lambda do |command, *args| + commands[:s1] << [command, *args] + ['127.0.0.1', port.to_s] + end + } + end + + master = { + auth: lambda do |user, pass| + commands[:m1] << ['auth', user, pass] + '+OK' + end, + role: lambda do + commands[:m1] << ['role'] + ['master'] + end + } + + RedisMock.start(master) do |master_port| + RedisMock.start(sentinel.call(master_port)) do |sen_port| + s = [{ host: '127.0.0.1', port: sen_port, username: 'bob', password: 'foo' }] + r = Redis.new(host: 'master1', sentinels: s, role: :master, username: 'alice', password: 'bar') + assert r.ping + end + end + + assert_equal [%w[auth bob foo], %w[get-master-addr-by-name master1]], commands[:s1] + assert_equal [%w[auth alice bar], %w[role]], commands[:m1] + end + def test_sentinel_role_mismatch sentinels = [{ host: "127.0.0.1", port: 26_381 }] diff --git a/test/transactions_test.rb b/test/transactions_test.rb index fd76af634de5e300ce2620bc9bc404295697e360..d86c6c1dfdd994807361d6aa62c647e634de86cc 100644 --- a/test/transactions_test.rb +++ b/test/transactions_test.rb @@ -10,6 +10,8 @@ class TestTransactions < Minitest::Test assert_equal "QUEUED", r.set("foo", "1") assert_equal "QUEUED", r.get("foo") + assert_equal "QUEUED", r.zincrby("bar", 1, "baz") # Floatify + assert_equal "QUEUED", r.hsetnx("plop", "foo", "bar") # Boolify r.discard diff --git a/test/url_param_test.rb b/test/url_param_test.rb index 17ff9548215095a17fe6b44bd523336f3092c522..59626c295da416ba0280e8cdae4048a661c1b7bc 100644 --- a/test/url_param_test.rb +++ b/test/url_param_test.rb @@ -134,4 +134,15 @@ class TestUrlParam < Minitest::Test assert_equal "127.0.0.1", redis._client.host end + + def test_user_and_password + redis = Redis.new(url: 'redis://johndoe:mysecret@foo.com:999/2') + + assert_equal('redis', redis._client.scheme) + assert_equal('johndoe', redis._client.username) + assert_equal('mysecret', redis._client.password) + assert_equal('foo.com', redis._client.host) + assert_equal(999, redis._client.port) + assert_equal(2, redis._client.db) + end end