projectblacklight/blacklight

View on GitHub
lib/blacklight/solr/response/facets.rb

Summary

Maintainability
A
3 hrs
Test Coverage
# frozen_string_literal: true

require 'ostruct'

module Blacklight::Solr::Response::Facets
  # represents a facet value; which is a field value and its hit count
  class FacetItem < OpenStruct
    def initialize *args
      options = args.extract_options!

      # Backwards-compat method signature
      value = args.shift
      hits = args.shift

      options[:value] = value if value
      options[:hits] = hits if hits

      super(options)
    end

    def label
      super || value
    end

    def as_json(props = nil)
      table.as_json(props)
    end
  end

  # represents a facet; which is a field and its values
  class FacetField
    attr_reader :name, :items
    attr_accessor :missing

    def initialize name, items, options = {}
      @name = name
      @items = items
      @options = options
    end

    def limit
      @options[:limit] || solr_default_limit
    end

    def sort
      @options[:sort] || solr_default_sort
    end

    def offset
      @options[:offset] || solr_default_offset
    end

    def prefix
      @options[:prefix] || solr_default_prefix
    end

    def type
      @options[:type] || 'terms'
    end

    def data
      @options[:data] || {}
    end

    def response
      @options[:response]
    end

    def index?
      sort == 'index'
    end

    def count?
      sort == 'count'
    end

    private

    # Per https://wiki.apache.org/solr/SimpleFacetParameters#facet.limit
    def solr_default_limit
      100
    end

    # Per https://wiki.apache.org/solr/SimpleFacetParameters#facet.sort
    def solr_default_sort
      if limit > 0
        'count'
      else
        'index'
      end
    end

    # Per https://wiki.apache.org/solr/SimpleFacetParameters#facet.offset
    def solr_default_offset
      0
    end

    def solr_default_prefix
      nil
    end
  end

  class NullFacetField < FacetField
    def initialize name, items = [], response: nil, **kwargs
      super
    end
  end

  ##
  # Get all the Solr facet data (fields, queries, pivots) as a hash keyed by
  # both the Solr field name and/or by the blacklight field name
  def aggregations
    @aggregations ||= default_aggregations.merge(facet_field_aggregations).merge(facet_query_aggregations).merge(facet_pivot_aggregations).merge(json_facet_aggregations)
  end

  def facet_counts
    @facet_counts ||= self['facet_counts'] || {}
  end

  # Returns the hash of all the facet_fields (ie: { 'instock_b' => ['true', 123, 'false', 20] }
  def facet_fields
    @facet_fields ||= begin
      val = facet_counts['facet_fields'] || {}

      # this is some old solr (1.4? earlier?) serialization of facet fields
      if val.is_a? Array
        val.to_h
      else
        val
      end
    end
  end

  # Returns all of the facet queries
  def facet_queries
    @facet_queries ||= facet_counts['facet_queries'] || {}
  end

  # Returns all of the facet queries
  def facet_pivot
    @facet_pivot ||= facet_counts['facet_pivot'] || {}
  end

  # Merge or add new facet count values to existing response
  def merge_facet(name:, value:, hits: nil)
    if dig('facet_counts', 'facet_fields', name)
      self['facet_counts']['facet_fields'][name] << value << hits
    else
      self['facet_counts']['facet_fields'][name] = [value, hits]
    end
  end

  private

  # @return [Hash] establish a null object pattern for facet data look-up, allowing
  #   the response and applied parameters to get passed through even if there was no
  #   facet data in the response
  def default_aggregations
    @default_aggregations ||= begin
      h = Hash.new { |key| null_facet_field_object(key) }
      h.with_indifferent_access
    end
  end

  # @return [Blacklight::Solr::Response::FacetField] a "null object" facet field
  def null_facet_field_object(key)
    Blacklight::Solr::Response::FacetField.new(key, [], facet_field_aggregation_options(key).merge(response: self))
  end

  ##
  # Convert Solr responses of various json.nl flavors to
  def list_as_hash solr_list
    # map
    if solr_list.values.first.is_a? Hash
      solr_list
    else
      solr_list.transform_values do |values|
        if values.first.is_a? Array
          # arrarr
          values.to_h
        else
          # flat
          values.each_slice(2).to_a.to_h
        end
      end
    end
  end

  ##
  # Convert Solr's facet_field response into
  # a hash of Blacklight::Solr::Response::Facet::FacetField objects
  def facet_field_aggregations
    list_as_hash(facet_fields).each_with_object({}) do |(facet_field_name, values), hash|
      items = values.map do |value, hits|
        i = FacetItem.new(value: value, hits: hits)

        # legacy solr facet.missing serialization
        if value.nil?
          i.label = I18n.t(:"blacklight.search.fields.facet.missing.#{facet_field_name}", default: [:'blacklight.search.facets.missing'])
          i.fq = "-#{facet_field_name}:[* TO *]" # this explicit fq is deprecated; the missing attribute below is a better thing to check for this case
          i.value = Blacklight::SearchState::FilterField::MISSING
          i.missing = true
        end

        i
      end

      options = facet_field_aggregation_options(facet_field_name)
      facet_field = FacetField.new(facet_field_name, items, options.merge(response: self))

      if values[nil]
        facet_field.missing = items.find(&:missing)
      end

      hash[facet_field_name] = facet_field

      # alias all the possible blacklight config names..
      blacklight_config.facet_fields.select { |_k, v| v.field == facet_field_name }.each_key do |key|
        hash[key] = hash[facet_field_name]
      end if blacklight_config && !blacklight_config.facet_fields[facet_field_name]
    end
  end

  ##
  # Aggregate Solr's facet_query response into the virtual facet fields defined
  # in the blacklight configuration
  def facet_query_aggregations
    return {} unless blacklight_config

    blacklight_config.facet_fields.select { |_k, v| v.query }.each_with_object({}) do |(field_name, facet_field), hash|
      salient_facet_queries = facet_field.query.map { |_k, x| x[:fq] }
      items = facet_queries.select { |k, _v| salient_facet_queries.include?(k) }.reject { |_value, hits| hits.zero? }.map do |value, hits|
        salient_fields = facet_field.query.select { |_key, val| val[:fq] == value }
        key = ((salient_fields.keys if salient_fields.respond_to? :keys) || salient_fields.first).first
        Blacklight::Solr::Response::Facets::FacetItem.new(value: key, hits: hits, label: facet_field.query[key][:label])
      end

      items += facet_query_aggregations_from_json(facet_field)

      items = items.sort_by(&:hits).reverse if facet_field.sort && facet_field.sort.to_sym == :count

      facet_field = Blacklight::Solr::Response::Facets::FacetField.new field_name, items, response: response

      hash[field_name] = facet_field
    end
  end

  def facet_query_aggregations_from_json(facet_field)
    return [] unless self['facets']

    salient_facet_queries = facet_field.query.map { |_k, x| x[:fq] }

    relevant_facet_data = self['facets'].select { |k, _v| salient_facet_queries.include?(k) }.reject { |_key, data| data['count'].zero? }

    relevant_facet_data.map do |key, data|
      salient_fields = facet_field.query.select { |_key, val| val[:fq] == key }
      facet_key = ((salient_fields.keys if salient_fields.respond_to? :keys) || salient_fields.first).first
      Blacklight::Solr::Response::Facets::FacetItem.new(value: facet_key, hits: data[:count], label: facet_field.query[facet_key][:label])
    end
  end

  ##
  # Convert Solr's facet_pivot response into
  # a hash of Blacklight::Solr::Response::Facet::FacetField objects
  def facet_pivot_aggregations
    facet_pivot.each_with_object({}) do |(field_name, values), hash|
      next unless blacklight_config && !blacklight_config.facet_fields[field_name]

      items = values.map do |lst|
        construct_pivot_field(lst)
      end

      # alias all the possible blacklight config names..
      blacklight_config.facet_fields.select { |_k, v| v.pivot && v.pivot.join(",") == field_name }.each_key do |key|
        facet_field = Blacklight::Solr::Response::Facets::FacetField.new key, items, response: self

        hash[key] = facet_field
      end
    end
  end

  ##
  # Recursively parse the pivot facet response to build up the full pivot tree
  def construct_pivot_field lst, parent_fq = {}
    items = Array(lst[:pivot]).map do |i|
      construct_pivot_field(i, parent_fq.merge({ lst[:field] => lst[:value] }))
    end

    Blacklight::Solr::Response::Facets::FacetItem.new(value: lst[:value], hits: lst[:count], field: lst[:field], items: items, fq: parent_fq)
  end

  def construct_json_nested_facet_fields(bucket, parent_fq = {})
    bucket.select { |_, nested| nested.is_a?(Hash) && nested.key?('buckets') }.map do |facet_field_name, nested|
      nested['buckets'].map do |subbucket|
        i = Blacklight::Solr::Response::Facets::FacetItem.new(field: facet_field_name, value: subbucket['val'], hits: subbucket['count'], fq: parent_fq, data: subbucket)

        i.items = construct_json_nested_facet_fields(subbucket, parent_fq.merge(key => subbucket['val'])) if has_json_nested_facets?(subbucket)
        i
      end
    end.flatten
  end

  def has_json_nested_facets?(bucket)
    bucket.any? { |_, nested| nested.is_a?(Hash) && nested.key?('buckets') }
  end

  def json_facet_aggregations
    return {} unless self['facets']

    self['facets'].each_with_object({}) do |(facet_field_name, data), hash|
      next if facet_field_name == 'count'

      items = (data['buckets'] || []).map do |bucket|
        i = Blacklight::Solr::Response::Facets::FacetItem.new(value: bucket['val'], hits: bucket['count'], data: bucket)

        i.items = construct_json_nested_facet_fields(bucket, facet_field_name => bucket['val']) if has_json_nested_facets?(bucket)

        i
      end

      options = facet_field_aggregation_options(facet_field_name).merge(data: data, response: self)
      facet_field = FacetField.new(facet_field_name, items, options)

      facet_field.missing = Blacklight::Solr::Response::Facets::FacetItem.new(
        hits: data.dig('missing', 'count'),
        data: data['missing']
      ) if data['missing']

      hash[facet_field_name] = facet_field
    end
  end
end