123456789_123456789_123456789_123456789_123456789_

Module: Redis::Commands::Search::ResultParser

Relationships & Source Files
Defined in: lib/redis/commands/modules/search/result.rb

Overview

Reply reshaping for the Query Engine.

Every parser normalises both RESP2 (flat arrays) and RESP3 (native maps) replies to the same Ruby objects, so the public API is stable regardless of the protocol the underlying connection negotiates. RESP3 is the default protocol; the RESP2 branches exist for protocol: 2 connections.

Class Method Summary

Class Method Details

.aggregate(reply) ⇒ AggregateResult? (mod_func)

FT.AGGREGATE / FT.CURSOR READ -> AggregateResult

Without a cursor the reply is [num_rows, row, row, ...] (RESP2) or a map with "results" (RESP3). With WITHCURSOR the reply is [aggregate_reply, cursor_id].

Parameters:

  • reply (Array, Hash)

    the FT.AGGREGATE / FT.CURSOR READ reply

Returns:

[ GitHub ]

  
# File 'lib/redis/commands/modules/search/result.rb', line 308

def aggregate(reply)
  return reply if reply.nil?

  cursor = nil
  body = reply
  # WITHCURSOR wraps the aggregate body and the cursor id in a 2-element array. The
  # body is a flat array under RESP2 and a native map under RESP3.
  if reply.is_a?(Array) && reply.length == 2 && reply[1].is_a?(Integer) &&
     (reply[0].is_a?(Array) || reply[0].is_a?(Hash))
    body = reply[0]
    cursor = reply[1]
  end

  rows =
    if body.is_a?(Hash) # RESP3
      (body["results"] || []).map { |row| hashify_fields(row["extra_attributes"] || row, {}) }
    else # RESP2: first element is the row count, the rest are rows
      body[1..-1].to_a.map { |row| hashify_fields(row, {}) }
    end

  AggregateResult.new(rows.map { |row| normalize_aggregate_row(row) }, cursor: cursor)
end

.config_get(reply) ⇒ Hash? (mod_func)

FT.CONFIG GET -> Hash. RESP2 returns nested [[option, value], ...] pairs; RESP3 a map.

Parameters:

  • reply (Array, Hash)

    the FT.CONFIG GET reply

Returns:

  • (Hash, nil)

    option => value (nil when reply is nil)

[ GitHub ]

  
# File 'lib/redis/commands/modules/search/result.rb', line 394

def config_get(reply)
  return reply if reply.nil?
  return reply if reply.is_a?(Hash)

  reply.to_h
end

.decode_value(value) ⇒ Object (mod_func)

JSON-decode a value, returning it unchanged when it is not a parseable JSON String.

Parameters:

  • value (Object)

    the value to decode

Returns:

  • (Object)

    the parsed value, or value when it is not a String or fails to parse

[ GitHub ]

  
# File 'lib/redis/commands/modules/search/result.rb', line 478

def decode_value(value)
  return value unless value.is_a?(String)

  ::JSON.parse(value)
rescue ::JSON::ParserError
  value
end

.hashify_fields(fields, decode_fields) (mod_func)

[ GitHub ]

  
# File 'lib/redis/commands/modules/search/result.rb', line 461

def hashify_fields(fields, decode_fields)
  return {} if fields.nil?

  # Reply field names are strings, but callers may key decode_fields with symbols
  # (e.g. Query#return_field(:brand)). Normalize so decoding is caller-type-independent.
  decode = decode_fields.transform_keys(&:to_s)
  pairs = fields.is_a?(Hash) ? fields.to_a : fields.each_slice(2).to_a

  pairs.each_with_object({}) do |(name, value), acc|
    acc[name] = decode[name.to_s] ? decode_value(value) : value
  end
end

.hashify_info(reply) ⇒ Hash? (mod_func)

FT.INFO / FT.CONFIG GET -> Hash. RESP2 returns a flat [k, v, ...] array; RESP3 already returns a native map.

Parameters:

  • reply (Array, Hash)

    the FT.INFO reply

Returns:

  • (Hash, nil)

    the info as a hash (nil when reply is nil)

[ GitHub ]

  
# File 'lib/redis/commands/modules/search/result.rb', line 383

def hashify_info(reply)
  return reply if reply.nil?
  return reply if reply.is_a?(Hash)

  Hash[*reply]
end

.hybrid(reply) ⇒ HybridResult? (mod_func)

FT.HYBRID -> HybridResult

The reply is a native map under RESP3 and a flat [k, v, ...] array under RESP2. A WITHCURSOR reply carries per-leg cursor ids ("SEARCH"/"VSIM") instead of a result page.

Parameters:

  • reply (Array, Hash)

    the FT.HYBRID reply

Returns:

  • (HybridResult, nil)

    the parsed result (nil when reply is nil)

[ GitHub ]

  
# File 'lib/redis/commands/modules/search/result.rb', line 356

def hybrid(reply)
  return reply if reply.nil?

  map = reply.is_a?(Hash) ? reply : Hash[*reply]

  if map.key?("SEARCH") || map.key?("VSIM")
    return HybridResult.new(
      warnings: map["warnings"] || [],
      search_cursor: map["SEARCH"],
      vsim_cursor: map["VSIM"]
    )
  end

  rows = Array(map["results"]).map { |row| hashify_fields(row, {}) }
  HybridResult.new(
    rows: rows,
    total: map["total_results"],
    warnings: map["warnings"] || [],
    execution_time: map["execution_time"]
  )
end

.normalize_aggregate_row(row) ⇒ Hash (mod_func)

A COLLECT reducer column is an array of entry-maps: Array under RESP3 but Array<[k, v, ...]> (flat key/value arrays) under RESP2. Normalize both to Array so callers see a uniform type regardless of protocol. Scalar values and arrays of scalars (e.g. TOLIST) are left untouched, and the RESP3 case is idempotent.

Parameters:

  • row (Hash)

    an already-hashified aggregate row

Returns:

  • (Hash)

    the row with any array-of-entries column reshaped to Array<Hash>

[ GitHub ]

  
# File 'lib/redis/commands/modules/search/result.rb', line 338

def normalize_aggregate_row(row)
  row.each_with_object({}) do |(key, value), acc|
    acc[key] =
      if value.is_a?(Array) && !value.empty? && value.all? { |e| e.is_a?(Array) || e.is_a?(Hash) }
        value.map { |entry| hashify_fields(entry, {}) }
      else
        value
      end
  end
end

.normalize_score(score) ⇒ Hash{String => Object} (mod_func)

Turn a flat [field, value, ...] array (RESP2) or a native map (RESP3) of returned fields into a hash, JSON-decoding values whose field is flagged in decode_fields.

Normalize a WITHSCORES value to a Float so Document#score is the same type regardless of protocol (RESP2 returns the score as a bulk string, RESP3 as a native double). With EXPLAINSCORE the RESP2 value is [score, explanation]; coerce the score part and keep the explanation. Anything non-numeric is returned untouched.

Parameters:

  • fields (Array, Hash, nil)

    the field/value pairs

  • decode_fields (Hash)

    field name => whether to JSON-decode its value

Returns:

  • (Hash{String => Object})

    the fields as a hash (empty when fields is nil)

[ GitHub ]

  
# File 'lib/redis/commands/modules/search/result.rb', line 451

def normalize_score(score)
  case score
  when nil then nil
  when Array then [normalize_score(score[0]), *score[1..]]
  else Float(score)
  end
rescue ArgumentError, TypeError
  score
end

.search(reply, with_scores: false, with_payloads: false, no_content: false, decode_fields: {}) ⇒ SearchResult? (mod_func)

FT.SEARCH -> SearchResult

Parameters:

  • reply (Array, Hash)

    RESP2 flat array or RESP3 map

  • with_scores (Boolean)

    WITHSCORES was set (RESP2 only; RESP3 carries it inline)

  • with_payloads (Boolean)

    WITHPAYLOADS was set (RESP2 only)

  • no_content (Boolean)

    NOCONTENT was set

  • decode_fields (Hash)

    field name => whether to JSON-decode its value

Returns:

  • (SearchResult, nil)

    the parsed result (nil when reply is nil)

[ GitHub ]

  
# File 'lib/redis/commands/modules/search/result.rb', line 235

def search(reply, with_scores: false, with_payloads: false, no_content: false, decode_fields: {})
  return reply if reply.nil?

  if reply.is_a?(Hash) # RESP3
    search_resp3(reply, decode_fields)
  else # RESP2 flat array
    search_resp2(reply, with_scores, with_payloads, no_content, decode_fields)
  end
end

.search_resp2(reply, with_scores, with_payloads, no_content, decode_fields) ⇒ SearchResult (mod_func)

Parse a RESP2 flat-array FT.SEARCH reply into a SearchResult.

Parameters:

  • reply (Array)

    the flat [total, id, ..., fields, ...] array

  • with_scores (Boolean)

    WITHSCORES was set

  • with_payloads (Boolean)

    WITHPAYLOADS was set

  • no_content (Boolean)

    NOCONTENT was set

  • decode_fields (Hash)

    field name => whether to JSON-decode its value

[ GitHub ]

  
# File 'lib/redis/commands/modules/search/result.rb', line 253

def search_resp2(reply, with_scores, with_payloads, no_content, decode_fields)
  total = reply[0]
  documents = []
  index = 1

  while index < reply.length
    id = reply[index]
    index += 1

    score = nil
    if with_scores
      score = normalize_score(reply[index])
      index += 1
    end

    payload = nil
    if with_payloads
      payload = reply[index]
      index += 1
    end

    attributes = {}
    unless no_content
      attributes = hashify_fields(reply[index], decode_fields)
      index += 1
    end

    documents << Document.new(id, attributes: attributes, score: score, payload: payload)
  end

  SearchResult.new(total, documents)
end

.search_resp3(reply, decode_fields) ⇒ SearchResult (mod_func)

Parse a RESP3 map FT.SEARCH reply into a SearchResult.

Parameters:

  • reply (Hash)

    the native map carrying "results" and "total_results"

  • decode_fields (Hash)

    field name => whether to JSON-decode its value

[ GitHub ]

  
# File 'lib/redis/commands/modules/search/result.rb', line 291

def search_resp3(reply, decode_fields)
  documents = (reply["results"] || []).map do |row|
    attributes = hashify_fields(row["extra_attributes"], decode_fields)
    Document.new(row["id"], attributes: attributes, score: normalize_score(row["score"]),
                            payload: row["payload"])
  end

  SearchResult.new(reply["total_results"], documents, warnings: reply["warning"] || [])
end

.spellcheck(reply) ⇒ Hash{String => Array<Hash>}? (mod_func)

FT.SPELLCHECK -> { term => [{ "suggestion" => ..., "score" => ... }, ...] }

Parameters:

  • reply (Array, Hash)

    the FT.SPELLCHECK reply

Returns:

  • (Hash{String => Array<Hash>}, nil)

    term => suggestions (nil when reply is nil)

[ GitHub ]

  
# File 'lib/redis/commands/modules/search/result.rb', line 416

def spellcheck(reply)
  return reply if reply.nil?

  # RESP3: { "results" => { term => [{ suggestion => score }, ...] } }
  if reply.is_a?(Hash)
    results = reply["results"] || {}
    return results.each_with_object({}) do |(term, suggestions), acc|
      acc[term] = suggestions.flat_map do |entry|
        entry.map { |suggestion, score| { "suggestion" => suggestion, "score" => score } }
      end
    end
  end

  # RESP2: [["TERM", term, [[score, suggestion], ...]], ...]
  parsed = {}
  reply.each do |entry|
    next unless entry[0] == "TERM"

    parsed[entry[1]] = entry[2].map do |score, suggestion|
      { "suggestion" => suggestion, "score" => score }
    end
  end
  parsed
end

.syndump(reply) ⇒ Hash{String => Array}? (mod_func)

FT.SYNDUMP -> { term => [group_id, ...] }. RESP2 is a flat [term, [ids], ...] array.

Parameters:

  • reply (Array, Hash)

    the FT.SYNDUMP reply

Returns:

  • (Hash{String => Array}, nil)

    term => group ids (nil when reply is nil)

[ GitHub ]

  
# File 'lib/redis/commands/modules/search/result.rb', line 405

def syndump(reply)
  return reply if reply.nil?
  return reply if reply.is_a?(Hash)

  Hash[*reply]
end