require 'redis' require 'redis/namespace/version' class Redis class Namespace # The following tables define how input parameters and result # values should be modified for the namespace. # # COMMANDS is a hash. Each key is the name of a command and each # value is a two element array. # # The first element in the value array describes how to modify the # arguments passed. It can be one of: # # nil # Do nothing. # :first # Add the namespace to the first argument passed, e.g. # GET key => GET namespace:key # :all # Add the namespace to all arguments passed, e.g. # MGET key1 key2 => MGET namespace:key1 namespace:key2 # :exclude_first # Add the namespace to all arguments but the first, e.g. # :exclude_last # Add the namespace to all arguments but the last, e.g. # BLPOP key1 key2 timeout => # BLPOP namespace:key1 namespace:key2 timeout # :exclude_options # Add the namespace to all arguments, except the last argument, # if the last argument is a hash of options. # ZUNIONSTORE key1 2 key2 key3 WEIGHTS 2 1 => # ZUNIONSTORE namespace:key1 2 namespace:key2 namespace:key3 WEIGHTS 2 1 # :alternate # Add the namespace to every other argument, e.g. # MSET key1 value1 key2 value2 => # MSET namespace:key1 value1 namespace:key2 value2 # :sort # Add namespace to first argument if it is non-nil # Add namespace to second arg's :by and :store if second arg is a Hash # Add namespace to each element in second arg's :get if second arg is # a Hash; forces second arg's :get to be an Array if present. # :eval_style # Add namespace to each element in keys argument (via options hash or multi-args) # :scan_style # Add namespace to :match option, or supplies "#{namespace}:*" if not present. # # The second element in the value array describes how to modify # the return value of the Redis call. It can be one of: # # nil # Do nothing. # :all # Add the namespace to all elements returned, e.g. # key1 key2 => namespace:key1 namespace:key2 NAMESPACED_COMMANDS = { "append" => [ :first ], "bitcount" => [ :first ], "bitop" => [ :exclude_first ], "bitpos" => [ :first ], "blpop" => [ :exclude_last, :first ], "brpop" => [ :exclude_last, :first ], "brpoplpush" => [ :exclude_last ], "bzpopmin" => [ :first ], "bzpopmax" => [ :first ], "debug" => [ :exclude_first ], "decr" => [ :first ], "decrby" => [ :first ], "del" => [ :all ], "dump" => [ :first ], "exists" => [ :all ], "exists?" => [ :all ], "expire" => [ :first ], "expireat" => [ :first ], "eval" => [ :eval_style ], "evalsha" => [ :eval_style ], "get" => [ :first ], "getex" => [ :first ], "getbit" => [ :first ], "getrange" => [ :first ], "getset" => [ :first ], "hset" => [ :first ], "hsetnx" => [ :first ], "hget" => [ :first ], "hincrby" => [ :first ], "hincrbyfloat" => [ :first ], "hmget" => [ :first ], "hmset" => [ :first ], "hdel" => [ :first ], "hexists" => [ :first ], "hlen" => [ :first ], "hkeys" => [ :first ], "hscan" => [ :first ], "hscan_each" => [ :first ], "hvals" => [ :first ], "hgetall" => [ :first ], "incr" => [ :first ], "incrby" => [ :first ], "incrbyfloat" => [ :first ], "keys" => [ :first, :all ], "lindex" => [ :first ], "linsert" => [ :first ], "llen" => [ :first ], "lpop" => [ :first ], "lpush" => [ :first ], "lpushx" => [ :first ], "lrange" => [ :first ], "lrem" => [ :first ], "lset" => [ :first ], "ltrim" => [ :first ], "mapped_hmset" => [ :first ], "mapped_hmget" => [ :first ], "mapped_mget" => [ :all, :all ], "mapped_mset" => [ :all ], "mapped_msetnx" => [ :all ], "mget" => [ :all ], "monitor" => [ :monitor ], "move" => [ :first ], "mset" => [ :alternate ], "msetnx" => [ :alternate ], "object" => [ :exclude_first ], "persist" => [ :first ], "pexpire" => [ :first ], "pexpireat" => [ :first ], "pfadd" => [ :first ], "pfcount" => [ :all ], "pfmerge" => [ :all ], "psetex" => [ :first ], "psubscribe" => [ :all ], "pttl" => [ :first ], "publish" => [ :first ], "punsubscribe" => [ :all ], "rename" => [ :all ], "renamenx" => [ :all ], "restore" => [ :first ], "rpop" => [ :first ], "rpoplpush" => [ :all ], "rpush" => [ :first ], "rpushx" => [ :first ], "sadd" => [ :first ], "sadd?" => [ :first ], "scard" => [ :first ], "scan" => [ :scan_style, :second ], "scan_each" => [ :scan_style, :all ], "sdiff" => [ :all ], "sdiffstore" => [ :all ], "set" => [ :first ], "setbit" => [ :first ], "setex" => [ :first ], "setnx" => [ :first ], "setrange" => [ :first ], "sinter" => [ :all ], "sinterstore" => [ :all ], "sismember" => [ :first ], "smembers" => [ :first ], "smismember" => [ :first ], "smove" => [ :exclude_last ], "sort" => [ :sort ], "spop" => [ :first ], "srandmember" => [ :first ], "srem" => [ :first ], "sscan" => [ :first ], "sscan_each" => [ :first ], "strlen" => [ :first ], "subscribe" => [ :all ], "sunion" => [ :all ], "sunionstore" => [ :all ], "ttl" => [ :first ], "type" => [ :first ], "unlink" => [ :all ], "unsubscribe" => [ :all ], "zadd" => [ :first ], "zcard" => [ :first ], "zcount" => [ :first ], "zincrby" => [ :first ], "zinterstore" => [ :exclude_options ], "zpopmin" => [ :first ], "zpopmax" => [ :first ], "zrange" => [ :first ], "zrangebyscore" => [ :first ], "zrangebylex" => [ :first ], "zrank" => [ :first ], "zrem" => [ :first ], "zremrangebyrank" => [ :first ], "zremrangebyscore" => [ :first ], "zremrangebylex" => [ :first ], "zrevrange" => [ :first ], "zrevrangebyscore" => [ :first ], "zrevrangebylex" => [ :first ], "zrevrank" => [ :first ], "zscan" => [ :first ], "zscan_each" => [ :first ], "zscore" => [ :first ], "zunionstore" => [ :exclude_options ] } TRANSACTION_COMMANDS = { "discard" => [], "exec" => [], "multi" => [], "unwatch" => [ :all ], "watch" => [ :all ], } HELPER_COMMANDS = { "auth" => [], "disconnect!" => [], "close" => [], "echo" => [], "ping" => [], "time" => [], } ADMINISTRATIVE_COMMANDS = { "bgrewriteaof" => [], "bgsave" => [], "config" => [], "dbsize" => [], "flushall" => [], "flushdb" => [], "info" => [], "lastsave" => [], "quit" => [], "randomkey" => [], "save" => [], "script" => [], "select" => [], "shutdown" => [], "slaveof" => [], } DEPRECATED_COMMANDS = [ ADMINISTRATIVE_COMMANDS ].compact.reduce(:merge) COMMANDS = [ NAMESPACED_COMMANDS, TRANSACTION_COMMANDS, HELPER_COMMANDS, ADMINISTRATIVE_COMMANDS, ].compact.reduce(:merge) # Support 1.8.7 by providing a namespaced reference to Enumerable::Enumerator Enumerator = Enumerable::Enumerator unless defined?(::Enumerator) # This is used by the Redis gem to determine whether or not to display that deprecation message. @sadd_returns_boolean = true class << self attr_accessor :sadd_returns_boolean end attr_writer :namespace attr_reader :redis attr_accessor :warning def initialize(namespace, options = {}) @namespace = namespace @redis = options[:redis] || Redis.new @warning = !!options.fetch(:warning) do !ENV['REDIS_NAMESPACE_QUIET'] end @deprecations = !!options.fetch(:deprecations) do ENV['REDIS_NAMESPACE_DEPRECATIONS'] end @has_new_client_method = @redis.respond_to?(:_client) end def deprecations? @deprecations end def warning? @warning end def client warn("The client method is deprecated as of redis-rb 4.0.0, please use the new _client " + "method instead. Support for the old method will be removed in redis-namespace 2.0.") if @has_new_client_method && deprecations? _client end def _client @has_new_client_method ? @redis._client : @redis.client # for redis-4.0.0 end # Ruby defines a now deprecated type method so we need to override it here # since it will never hit method_missing def type(key) call_with_namespace(:type, key) end alias_method :self_respond_to?, :respond_to? # emulate Ruby 1.9+ and keep respond_to_missing? logic together. def respond_to?(command, include_private=false) return !deprecations? if DEPRECATED_COMMANDS.include?(command.to_s.downcase) respond_to_missing?(command, include_private) or super end def keys(query = nil) call_with_namespace(:keys, query || '*') end def multi(&block) if block_given? namespaced_block(:multi, &block) else call_with_namespace(:multi) end end def pipelined(&block) namespaced_block(:pipelined, &block) end def namespace(desired_namespace = nil) if desired_namespace yield Redis::Namespace.new(desired_namespace, :redis => @redis) end @namespace.respond_to?(:call) ? @namespace.call : @namespace end def full_namespace redis.is_a?(Namespace) ? "#{redis.full_namespace}:#{namespace}" : namespace.to_s end def connection @redis.connection.tap { |info| info[:namespace] = namespace } end def exec call_with_namespace(:exec) end def eval(*args) call_with_namespace(:eval, *args) end ruby2_keywords(:eval) if respond_to?(:ruby2_keywords, true) # This operation can run for a very long time if the namespace contains lots of keys! # It should be used in tests, or when the namespace is small enough # and you are sure you know what you are doing. def clear if warning? warn("This operation can run for a very long time if the namespace contains lots of keys! " + "It should be used in tests, or when the namespace is small enough " + "and you are sure you know what you are doing.") end batch_size = 1000 if supports_scan? cursor = "0" begin cursor, keys = scan(cursor, count: batch_size) del(*keys) unless keys.empty? end until cursor == "0" else all_keys = keys("*") all_keys.each_slice(batch_size) do |keys| del(*keys) end end end ADMINISTRATIVE_COMMANDS.keys.each do |command| define_method(command) do |*args, &block| raise NoMethodError if deprecations? if warning? warn("Passing '#{command}' command to redis as is; " + "administrative commands cannot be effectively namespaced " + "and should be called on the redis connection directly; " + "passthrough has been deprecated and will be removed in " + "redis-namespace 2.0 (at #{call_site})" ) end call_with_namespace(command, *args, &block) end ruby2_keywords(command) if respond_to?(:ruby2_keywords, true) end COMMANDS.keys.each do |command| next if ADMINISTRATIVE_COMMANDS.include?(command) next if method_defined?(command) define_method(command) do |*args, &block| call_with_namespace(command, *args, &block) end ruby2_keywords(command) if respond_to?(:ruby2_keywords, true) end def method_missing(command, *args, &block) normalized_command = command.to_s.downcase if COMMANDS.include?(normalized_command) send(normalized_command, *args, &block) elsif @redis.respond_to?(normalized_command) && !deprecations? # blind passthrough is deprecated and will be removed in 2.0 # redis-namespace does not know how to handle this command. # Passing it to @redis as is, where redis-namespace shows # a warning message if @warning is set. if warning? warn("Passing '#{command}' command to redis as is; blind " + "passthrough has been deprecated and will be removed in " + "redis-namespace 2.0 (at #{call_site})") end wrapped_send(@redis, command, args, &block) else super end end ruby2_keywords(:method_missing) if respond_to?(:ruby2_keywords, true) def inspect "<#{self.class.name} v#{VERSION} with client v#{Redis::VERSION} "\ "for #{@redis.id}/#{full_namespace}>" end def respond_to_missing?(command, include_all=false) normalized_command = command.to_s.downcase case when COMMANDS.include?(normalized_command) true when !deprecations? && redis.respond_to?(command, include_all) true else defined?(super) && super end end def call_with_namespace(command, *args, &block) handling = COMMANDS[command.to_s.downcase] if handling.nil? fail("Redis::Namespace does not know how to handle '#{command}'.") end (before, after) = handling # Modify the local *args array in-place, no need to copy it. args.map! {|arg| clone_args(arg)} # Add the namespace to any parameters that are keys. case before when :first args[0] = add_namespace(args[0]) if args[0] args[-1] = ruby2_keywords_hash(args[-1]) if args[-1].is_a?(Hash) when :all args = add_namespace(args) when :exclude_first first = args.shift args = add_namespace(args) args.unshift(first) if first when :exclude_last last = args.pop unless args.length == 1 args = add_namespace(args) args.push(last) if last when :exclude_options if args.last.is_a?(Hash) last = ruby2_keywords_hash(args.pop) args = add_namespace(args) args.push(last) else args = add_namespace(args) end when :alternate args = args.flatten args.each_with_index { |a, i| args[i] = add_namespace(a) if i.even? } when :sort args[0] = add_namespace(args[0]) if args[0] if args[1].is_a?(Hash) [:by, :store].each do |key| args[1][key] = add_namespace(args[1][key]) if args[1][key] end args[1][:get] = Array(args[1][:get]) args[1][:get].each_index do |i| args[1][:get][i] = add_namespace(args[1][:get][i]) unless args[1][:get][i] == "#" end args[1] = ruby2_keywords_hash(args[1]) end when :eval_style # redis.eval() and evalsha() can either take the form: # # redis.eval(script, [key1, key2], [argv1, argv2]) # # Or: # # redis.eval(script, :keys => ['k1', 'k2'], :argv => ['arg1', 'arg2']) # # This is a tricky + annoying special case, where we only want the `keys` # argument to be namespaced. if args.last.is_a?(Hash) args.last[:keys] = add_namespace(args.last[:keys]) else args[1] = add_namespace(args[1]) end when :scan_style options = (args.last.kind_of?(Hash) ? args.pop : {}) options[:match] = add_namespace(options.fetch(:match, '*')) args << ruby2_keywords_hash(options) if block original_block = block block = proc { |key| original_block.call rem_namespace(key) } end end # Dispatch the command to Redis and store the result. result = wrapped_send(@redis, command, args, &block) # Don't try to remove namespace from a Redis::Future, you can't. return result if result.is_a?(Redis::Future) # Remove the namespace from results that are keys. case after when :all result = rem_namespace(result) when :first result[0] = rem_namespace(result[0]) if result when :second result[1] = rem_namespace(result[1]) if result end result end ruby2_keywords(:call_with_namespace) if respond_to?(:ruby2_keywords, true) protected def redis=(redis) @redis = redis end private if Hash.respond_to?(:ruby2_keywords_hash) def ruby2_keywords_hash(kwargs) Hash.ruby2_keywords_hash(kwargs) end else def ruby2_keywords_hash(kwargs) kwargs end end def wrapped_send(redis_client, command, args = [], &block) if redis_client.class.name == "ConnectionPool" redis_client.with do |pool_connection| pool_connection.send(command, *args, &block) end else redis_client.send(command, *args, &block) end end # Avoid modifying the caller's (pass-by-reference) arguments. def clone_args(arg) if arg.is_a?(Array) arg.map {|sub_arg| clone_args(sub_arg)} elsif arg.is_a?(Hash) Hash[arg.map {|k, v| [clone_args(k), clone_args(v)]}] else arg # Some objects (e.g. symbol) can't be dup'd. end end def call_site caller.reject { |l| l.start_with?(__FILE__) }.first end def namespaced_block(command, &block) if block.arity == 0 wrapped_send(redis, command, &block) else outer_block = proc { |r| copy = dup; copy.redis = r; yield copy } wrapped_send(redis, command, &outer_block) end end def add_namespace(key) return key unless key && namespace case key when Array key.map! {|k| add_namespace k} when Hash key.keys.each {|k| key[add_namespace(k)] = key.delete(k)} key else "#{namespace}:#{key}" end end def rem_namespace(key) return key unless key && namespace case key when Array key.map {|k| rem_namespace k} when Hash Hash[*key.map {|k, v| [ rem_namespace(k), v ]}.flatten] when Enumerator create_enumerator do |yielder| key.each { |k| yielder.yield rem_namespace(k) } end else key.to_s.sub(/\A#{namespace}:/, '') end end def create_enumerator(&block) # Enumerator in 1.8.7 *requires* a single argument, so we need to use # its Generator class, which matches the block syntax of 1.9.x's # Enumerator class. if RUBY_VERSION.start_with?('1.8') require 'generator' unless defined?(Generator) Generator.new(&block).to_enum else Enumerator.new(&block) end end def supports_scan? redis_version = @redis.info["redis_version"] Gem::Version.new(redis_version) >= Gem::Version.new("2.8.0") end end end