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

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 collapse

Class Method Details

.aggregate(reply) ⇒ AggregateResult?

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:



308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
# 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?

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)



394
395
396
397
398
399
# 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

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



478
479
480
481
482
483
484
# 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) ⇒ Object



461
462
463
464
465
466
467
468
469
470
471
472
# 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?

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)



383
384
385
386
387
388
# 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?

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)



356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
# 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

A COLLECT reducer column is an array of entry-maps: Array<Hash> under RESP3 but Array<[k, v, ...]> (flat key/value arrays) under RESP2. Normalize both to Array<Hash> 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>



338
339
340
341
342
343
344
345
346
347
# 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}

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)



451
452
453
454
455
456
457
458
459
# 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?

FT.SEARCH -> SearchResult

Parameters:

  • reply (Array, Hash)

    RESP2 flat array or RESP3 map

  • with_scores (Boolean) (defaults to: false)

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

  • with_payloads (Boolean) (defaults to: false)

    WITHPAYLOADS was set (RESP2 only)

  • no_content (Boolean) (defaults to: false)

    NOCONTENT was set

  • decode_fields (Hash) (defaults to: {})

    field name => whether to JSON-decode its value

Returns:

  • (SearchResult, nil)

    the parsed result (nil when reply is nil)



235
236
237
238
239
240
241
242
243
# 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

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

Returns:



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
# 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

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

Returns:



291
292
293
294
295
296
297
298
299
# 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>}?

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)



416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
# 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}?

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)



405
406
407
408
409
410
# 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