Module: Valkey::Utils

Included in:
Valkey
Defined in:
lib/valkey/utils.rb

Overview

Valkey Utils module

This module provides utility functions for transforming and processing data structures commonly used in Valkey commands.

It includes methods for converting values to boolean, hash, or float, as well as methods for handling specific Valkey command responses.

Constant Summary collapse

Boolify =
lambda { |value|
  return value if value.is_a?(TrueClass) || value.is_a?(FalseClass)

  value != 0 unless value.nil?
}
BoolifySet =
lambda { |value|
  case value
  when "OK"
    true
  when nil
    false
  else
    value
  end
}
Hashify =
lambda { |value|
  if value.is_a?(Hash)
    value
  elsif value.respond_to?(:each_slice)
    if value.first.is_a?(Array)
      value.to_h
    else
      value.each_slice(2).to_h
    end
  else
    value
  end
}
Pairify =
lambda { |value|
  if value.respond_to?(:each_slice)
    if value.first.is_a?(Array)
      value
    else
      value.each_slice(2).to_a
    end
  else
    value
  end
}
Floatify =
lambda { |value|
  case value
  when "inf"
    Float::INFINITY
  when "-inf"
    -Float::INFINITY
  when String
    Float(value)
  else
    value
  end
}
FloatifyPair =
lambda { |(first, score)|
  [first, Floatify.call(score)]
}
FloatifyPairs =
lambda { |value|
  return value unless value.respond_to?(:each_slice)

  if value.first.is_a?(Array)
    value.map(&FloatifyPair)
  else
    value.each_slice(2).map(&FloatifyPair)
  end
}
HashifyInfo =
lambda { |reply|
  lines = reply.split("\r\n").grep_v(/^(#|$)/)
  lines.map! { |line| line.split(':', 2) }
  lines.compact!
  lines.to_h
}
HashifyStreams =
lambda { |reply|
  case reply
  when nil
    {}
  else
    reply.transform_values { |entries| HashifyStreamEntries.call(entries) }
  end
}
HashifyStreamEntries =
lambda { |reply|
  return [] if reply.nil?

  # In cluster mode, MAP responses come as Hash: {id => [fields], ...}
  if reply.is_a?(Hash)
    return reply.map { |entry_id, values| [entry_id, values.is_a?(Array) ? values.flatten : []] }
  end

  return [] if !reply.is_a?(Array) || reply.empty?

  # Reply format: [[entry_id, [field1, value1, field2, value2, ...]], ...]
  # Match redis-rb: return flat arrays [["id", ["field", "value", ...]], ...]
  # Check if first element is a pair [entry_id, values_array]
  first_elem = reply.first
  if first_elem.is_a?(Array) && first_elem.length == 2
    # Already in pair format: [[entry_id, [fields...]], ...]
    reply.compact.map do |entry_id, values|
      # Return flat array format like redis-rb, not hash
      values_array = if values.nil?
                       []
                     elsif values.is_a?(Array)
                       values
                     else
                       []
                     end
      [entry_id, values_array]
    end
  else
    # Flat array format: [entry_id1, [field1, value1, ...], entry_id2, [field2, value2, ...], ...]
    reply.compact.each_slice(2).map do |entry_id, values|
      # Return flat array format like redis-rb, not hash
      values_array = if values.nil?
                       []
                     elsif values.is_a?(Array)
                       values
                     else
                       []
                     end
      [entry_id, values_array]
    end
  end
}
HashifyStreamAutoclaim =
lambda { |reply|
  {
    'next' => reply[0],
    'entries' => if reply[1].nil?
                   []
                 elsif reply[1].is_a?(Array)
                   # Reply[1] is already an array of entries: [[id, [field, value, ...]], ...]
                   # Use HashifyStreamEntries to convert them properly
                   HashifyStreamEntries.call(reply[1])
                 else
                   []
                 end
  }
}
HashifyStreamAutoclaimJustId =
lambda { |reply|
  {
    'next' => reply[0],
    'entries' => reply[1]
  }
}
HashifyStreamPendings =
lambda { |reply|
  {
    'size' => reply[0],
    'min_entry_id' => reply[1],
    'max_entry_id' => reply[2],
    'consumers' => reply[3].nil? ? {} : reply[3].to_h
  }
}
HashifyStreamPendingDetails =
lambda { |reply|
  reply.map do |arr|
    {
      'entry_id' => arr[0],
      'consumer' => arr[1],
      'elapsed' => arr[2],
      'count' => arr[3]
    }
  end
}
HashifyClusterNodeInfo =
lambda { |str|
  arr = str.split
  {
    'node_id' => arr[0],
    'ip_port' => arr[1],
    'flags' => arr[2].split(','),
    'master_node_id' => arr[3],
    'ping_sent' => arr[4],
    'pong_recv' => arr[5],
    'config_epoch' => arr[6],
    'link_state' => arr[7],
    'slots' => arr[8].nil? ? nil : Range.new(*arr[8].split('-'))
  }
}
HashifyClusterSlots =
lambda { |reply|
  reply.map do |arr|
    first_slot, last_slot = arr[0..1]
    master = { 'ip' => arr[2][0], 'port' => arr[2][1], 'node_id' => arr[2][2] }
    replicas = arr[3..].map { |r| { 'ip' => r[0], 'port' => r[1], 'node_id' => r[2] } }
    {
      'start_slot' => first_slot,
      'end_slot' => last_slot,
      'master' => master,
      'replicas' => replicas
    }
  end
}
HashifyClusterNodes =
lambda { |reply|
  reply.split(/[\r\n]+/).map { |str| HashifyClusterNodeInfo.call(str) }
}
HashifyClusterSlaves =
lambda { |reply|
  reply.map { |str| HashifyClusterNodeInfo.call(str) }
}
Noop =
->(reply) { reply }

Class Method Summary collapse

Class Method Details

.parse_redis_url(url) ⇒ Object

Raises:

  • (ArgumentError)


249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
# File 'lib/valkey/utils.rb', line 249

def self.parse_redis_url(url)
  return {} unless url.is_a?(String) && !url.empty?

  # Redact userinfo before it ends up in error messages, logs, or trackers.
  # The regex greedily consumes up to the LAST `@` after `://`, so passwords
  # containing `/` or `@` are still fully redacted (RFC 3986 allows both in
  # percent-decoded form; either can appear raw in a user-supplied URL).
  redacted = url.sub(%r{(\A[^:]+://).*@}, '\1[REDACTED]@').inspect

  uri = begin
    URI.parse(url)
  rescue URI::InvalidURIError
    raise ArgumentError, "Invalid Valkey URL #{redacted}: URI could not be parsed"
  end

  unless ALLOWED_URL_SCHEMES.include?(uri.scheme)
    raise ArgumentError,
          "Invalid Valkey URL #{redacted}: scheme must be one of " \
          "#{ALLOWED_URL_SCHEMES.join(', ')}, got #{uri.scheme.inspect}"
  end

  host = uri.hostname
  raise ArgumentError, "Invalid Valkey URL #{redacted}: missing host" if host.to_s.empty?

  result = {
    host: host,
    port: uri.port || 6379,
    ssl: TLS_URL_SCHEMES.include?(uri.scheme)
  }

  db_segment = uri.path.to_s.delete_prefix('/')
  unless db_segment.empty?
    unless db_segment.match?(/\A\d+\z/)
      raise ArgumentError, "Invalid Valkey URL #{redacted}: database must be a non-negative integer"
    end

    result[:db] = db_segment.to_i
  end

  # `URI.parse` returns userinfo *percent-encoded* (RFC 3986 §3.2.1); we
  # want the raw credential value. Callers (`Valkey#initialize`) will
  # re-encode with `URI::RFC2396_PARSER.escape` when composing the URI
  # they hand to the FFI, so leaving the encoded form here would
  # double-encode (`p%40ss` → `p%2540ss`). See valkey-glide/issues/6659.
  result[:username] = URI::RFC2396_PARSER.unescape(uri.user) if uri.user && !uri.user.empty?
  result[:password] = URI::RFC2396_PARSER.unescape(uri.password) if uri.password && !uri.password.empty?

  result
end