class Brainstem::Presenter

@abstract Subclass and override {#present} to implement a presenter.

Constants

TIME_CLASSES

This constant stores an array of classes that we will treat as times. Unfortunately, ActiveSupport::TimeWithZone does not descend from Time, so we put them into this array for later use.

Public Class Methods

merged_helper_class() click to toggle source
# File lib/brainstem/presenter.rb, line 48
def self.merged_helper_class
  @helper_classes ||= {}

  @helper_classes[configuration[:helpers].to_a.map(&:object_id)] ||= begin
    Class.new.tap do |klass|
      (configuration[:helpers] || []).each do |helper|
        klass.send :include, helper
      end
    end
  end
end
namespace() click to toggle source

Return the second-to-last module in the name of this presenter, which Brainstem considers to be the 'namespace'. E.g., Api::V1::FooPresenter has a namespace of “V1”. @return [String] The name of the second-to-last module containing this presenter.

# File lib/brainstem/presenter.rb, line 44
def self.namespace
  self.to_s.split("::")[-2].try(:downcase)
end
possible_brainstem_keys() click to toggle source

Returns the set of possible brainstem keys for the classes presented.

If the presenter specifies a key, that will be returned as the only member of the set.

# File lib/brainstem/presenter.rb, line 35
def self.possible_brainstem_keys
  @possible_brainstem_keys ||= begin
    Set.new(presents.map(&presenter_collection.method(:brainstem_key_for!)))
  end
end
presents(*klasses) click to toggle source

Accepts a list of classes that this specific presenter knows how to present. These are not inherited. @param [String, [String]] klasses Any number of names of classes this presenter presents.

# File lib/brainstem/presenter.rb, line 17
def self.presents(*klasses)
  @presents ||= []
  if klasses.length > 0
    if klasses.any? { |klass| klass.is_a?(String) || klass.is_a?(Symbol) }
      raise "Brainstem Presenter#presents now expects a Class instead of a class name"
    end
    @presents.concat(klasses).uniq!
    Brainstem.add_presenter_class(self, namespace, *klasses)
  end
  @presents
end
reflections(klass) click to toggle source

In Rails 4.2, ActiveRecord::Base#reflections started being keyed by strings instead of symbols.

# File lib/brainstem/presenter.rb, line 66
def self.reflections(klass)
  klass.reflections.each_with_object({}) { |(key, value), memo| memo[key.to_s] = value }
end
reset!() click to toggle source
# File lib/brainstem/presenter.rb, line 60
def self.reset!
  clear_configuration!
  @helper_classes = @presents = nil
end

Protected Class Methods

presenter_collection() click to toggle source
# File lib/brainstem/presenter.rb, line 411
def self.presenter_collection
  Brainstem.presenter_collection(namespace)
end

Public Instance Methods

allowed_associations(is_only_query) click to toggle source

@api private Determines which associations are valid for inclusion in the current context. Mostly just removes only-restricted associations when needed. @return [Hash] The associations that can be included.

# File lib/brainstem/presenter.rb, line 139
def allowed_associations(is_only_query)
  ActiveSupport::HashWithIndifferentAccess.new.tap do |associations|
    configuration[:associations].each do |name, association|
      associations[name] = association unless association.options[:restrict_to_only] && !is_only_query
    end
  end
end
apply_filters_to_scope(scope, user_params, options) click to toggle source

Given user params, build a hash of validated filter names to their unsanitized arguments.

# File lib/brainstem/presenter.rb, line 186
def apply_filters_to_scope(scope, user_params, options)
  helper_instance = fresh_helper_instance

  requested_filters = extract_filters(user_params, options)
  requested_filters.each do |filter_name, filter_arg|
    filter_lambda = configuration[:filters][filter_name][:value]

    args_for_filter_lambda = [filter_arg]
    args_for_filter_lambda << requested_filters if configuration[:filters][filter_name][:include_params]

    if filter_lambda
      scope = helper_instance.instance_exec(scope, *args_for_filter_lambda, &filter_lambda)
    else
      scope = scope.send(filter_name, *args_for_filter_lambda)
    end
  end

  scope
end
apply_ordering_to_scope(scope, user_params) click to toggle source

Given user params, apply a validated sort order to the given scope.

# File lib/brainstem/presenter.rb, line 207
def apply_ordering_to_scope(scope, user_params)
  sort_name, direction = calculate_sort_name_and_direction(user_params)
  order = configuration[:sort_orders].fetch(sort_name, {})[:value]

  ordered_scope = case order
    when Proc
      fresh_helper_instance.instance_exec(scope, direction, &order)
    when nil
      scope
    else
      scope.reorder(Arel.sql(order.to_s + " " + direction))
  end

  fallback_deterministic_sort = assemble_primary_key_sort(scope)
  # Chain on a tiebreaker sort to ensure deterministic ordering of multiple pages of data

  if fallback_deterministic_sort
    ordered_scope.order(fallback_deterministic_sort)
  else
    ordered_scope
  end
end
calculate_sort_name_and_direction(user_params = {}) click to toggle source

Clean and validate a sort order and direction from user params.

# File lib/brainstem/presenter.rb, line 248
def calculate_sort_name_and_direction(user_params = {})
  default_column, default_direction = (configuration[:default_sort_order] || "updated_at:desc").split(":")
  sort_name, direction = user_params['order'].to_s.split(":")
  unless sort_name.present? && configuration[:sort_orders][sort_name]
    sort_name = default_column
    direction = default_direction
  end

  [sort_name, direction == 'desc' ? 'desc' : 'asc']
end
custom_preload(models, requested_associations = []) click to toggle source

Subclasses can define this if they wish. This method will be called by {#group_present}.

# File lib/brainstem/presenter.rb, line 148
def custom_preload(models, requested_associations = [])
end
extract_filters(user_params, options = {}) click to toggle source

Given user params, build a hash of validated filter names to their unsanitized arguments.

# File lib/brainstem/presenter.rb, line 152
def extract_filters(user_params, options = {})
  filters_hash = {}

  apply_default_filters = options.fetch(:apply_default_filters) { true }

  configuration[:filters].each do |filter_name, filter_options|
    user_value = format_filter_value(user_params[filter_name])

    filter_arg = apply_default_filters && user_value.nil? ? filter_options[:default] : user_value
    filters_hash[filter_name] = filter_arg unless filter_arg.nil?
  end

  filters_hash
end
get_query_strategy() click to toggle source
# File lib/brainstem/presenter.rb, line 77
def get_query_strategy
  if configuration.has_key? :query_strategy
    strat = configuration[:query_strategy]
    strat.respond_to?(:call) ? fresh_helper_instance.instance_exec(&strat) : strat
  end
end
group_present(models, requested_associations = [], options = {}) click to toggle source

Calls {#custom_preload} and then presents all models. @params [ActiveRecord::Relation, Array] models @params [Array] requested_associations An array of permitted lower-case string association names, e.g. 'post' @params [Hash] options The options passed to `load_associations!`

# File lib/brainstem/presenter.rb, line 88
def group_present(models, requested_associations = [], options = {})
  association_objects_by_name = requested_associations.each_with_object({}) do |assoc_name, memo|
    memo[assoc_name.to_s] = configuration[:associations][assoc_name] if configuration[:associations][assoc_name]
  end

  # It's slightly ugly, but more efficient if we pre-load everything we
  # need and pass it through.
  context = {
    conditional_cache:            { request: {} },
    fields:                       configuration[:fields],
    conditionals:                 configuration[:conditionals],
    associations:                 configuration[:associations],
    reflections:                  reflections_for_model(models.first),
    association_objects_by_name:  association_objects_by_name,
    optional_fields:              options[:optional_fields] || [],
    models:                       models,
    lookup:                       empty_lookup_cache(configuration[:fields].keys, association_objects_by_name.keys)
  }

  sanitized_association_names = association_objects_by_name.values.map(&:method_name)
  preload_associations! models, sanitized_association_names, context[:reflections]

  # Legacy: Overridable for custom preload behavior.
  custom_preload(models, association_objects_by_name.keys)

  models.map do |model|
    context[:conditional_cache][:model] = {}
    context[:helper_instance] = fresh_helper_instance
    result = present_fields(model, context, context[:fields])
    load_associations!(model, result, context, options)
    add_id!(model, result)
    datetimes_to_json(result)
  end
end
present(model) click to toggle source

@deprecated

# File lib/brainstem/presenter.rb, line 73
def present(model)
  raise "#present is now deprecated"
end
present_model(model, requested_associations = [], options = {}) click to toggle source
# File lib/brainstem/presenter.rb, line 123
def present_model(model, requested_associations = [], options = {})
  group_present([model], requested_associations, options).first
end

Protected Instance Methods

add_id!(model, struct) click to toggle source

@api protected Adds :id as a string from the given model.

# File lib/brainstem/presenter.rb, line 278
def add_id!(model, struct)
  if model.class.respond_to?(:primary_key)
    struct['id'] = model[model.class.primary_key].to_s
  end
end
add_ids_or_refs_to_struct!(struct, association, external_name, associated_model_or_models) click to toggle source

@api protected Inject 'foo_ids' keys into the presented data if the foos association has been requested.

# File lib/brainstem/presenter.rb, line 363
def add_ids_or_refs_to_struct!(struct, association, external_name, associated_model_or_models)
  singular_external_name = external_name.to_s.singularize
  if association.polymorphic?
    if associated_model_or_models.is_a?(Array) || associated_model_or_models.is_a?(ActiveRecord::Relation)
      struct["#{singular_external_name}_refs"] = associated_model_or_models.map { |associated_model| make_model_ref(associated_model) }
    else
      struct["#{singular_external_name}_ref"] = make_model_ref(associated_model_or_models)
    end
  else
    if associated_model_or_models.is_a?(Array) || associated_model_or_models.is_a?(ActiveRecord::Relation)
      struct["#{singular_external_name}_ids"] = associated_model_or_models.map { |associated_model| to_s_except_nil(associated_model.try(:id)) }
    else
      struct["#{singular_external_name}_id"] = to_s_except_nil(associated_model_or_models.try(:id))
    end
  end
end
datetimes_to_json(struct) click to toggle source

@api protected Recurses through any nested Hash/Array data structure, converting dates and times to JSON standard values.

# File lib/brainstem/presenter.rb, line 286
def datetimes_to_json(struct)
  case struct
  when Array
    struct.map { |value| datetimes_to_json(value) }
  when Hash
    processed = {}
    struct.each { |k,v| processed[k] = datetimes_to_json(v) }
    processed
  when Date
    struct.strftime('%F')
  when *TIME_CLASSES # Time, ActiveSupport::TimeWithZone
    struct.iso8601
  else
    struct
  end
end
empty_lookup_cache(field_keys, association_keys) click to toggle source

@api protected Create an empty lookup cache with the fields and associations as keys and nil for the values @return [Hash]

# File lib/brainstem/presenter.rb, line 424
def empty_lookup_cache(field_keys, association_keys)
  {
    fields: Hash[field_keys.map { |key| [key, nil] }],
    associations: Hash[association_keys.map { |key| [key, nil] }]
  }
end
fresh_helper_instance() click to toggle source

@api protected Instantiate and return a new instance of the merged helper class for this presenter.

# File lib/brainstem/presenter.rb, line 272
def fresh_helper_instance
  self.class.merged_helper_class.new
end
legacy_polymorphic_base_ref(model, id_attr, method_name) click to toggle source

@api protected Deprecated support for legacy always-return-ref mode without loading the association. This tries to find the key based on the *_type value in the DB (which will be the STI base class, and may error if no presenter exists)

# File lib/brainstem/presenter.rb, line 383
def legacy_polymorphic_base_ref(model, id_attr, method_name)
  if (id = model.send(id_attr)).present?
    {
      'id' => to_s_except_nil(id),
      'key' => presenter_collection.brainstem_key_for!(model.send("#{method_name}_type").try(:constantize))
    }
  end
end
load_associations!(model, struct, context, options) click to toggle source

@api protected Makes sure that associations are loaded and converted into ids.

# File lib/brainstem/presenter.rb, line 329
def load_associations!(model, struct, context, options)
  context[:associations].each do |external_name, association|
    method_name = association.method_name && association.method_name.to_s
    id_attr = method_name && "#{method_name}_id"

    # If this association has been explictly requested, execute the association here.  Additionally, store
    # the loaded models in the :load_associations_into hash for later use.
    if context[:association_objects_by_name][external_name]
      associated_model_or_models = association.run_on(model, context, context[:helper_instance])

      if options[:load_associations_into]
        Array(associated_model_or_models).flatten.each do |associated_model|
          key = presenter_collection.brainstem_key_for!(associated_model.class)
          options[:load_associations_into][key] ||= {}
          options[:load_associations_into][key][associated_model.id.to_s] = associated_model
        end
      end
    end

    if id_attr && model.class.columns_hash.has_key?(id_attr) && !association.polymorphic?
      # We return *_id keys when they exist in the database, because it's free to do so.
      struct["#{external_name}_id"] = to_s_except_nil(model.send(id_attr))
    elsif association.always_return_ref_with_sti_base?
      # Deprecated support for legacy always-return-ref mode without loading the association.
      struct["#{external_name}_ref"] = legacy_polymorphic_base_ref(model, id_attr, method_name)
    elsif context[:association_objects_by_name][external_name]
      # This association has been explicitly requested.  Add the *_id, *_ids, *_ref, or *_refs keys to the presented data.
      add_ids_or_refs_to_struct!(struct, association, external_name, associated_model_or_models)
    end
  end
end
make_model_ref(model) click to toggle source

@api protected Return a polymorphic id/key object for a model, or nil if no model was given.

# File lib/brainstem/presenter.rb, line 400
def make_model_ref(model)
  if model
    {
      'id' => to_s_except_nil(model.id),
      'key' => presenter_collection.brainstem_key_for!(model.class)
    }
  else
    nil
  end
end
preload_associations!(models, sanitized_association_names, memoized_reflections) click to toggle source

@api protected Run preloading on the given models, asking Rails to include both any named associations and any preloads declared in the Brainstem DSL..

# File lib/brainstem/presenter.rb, line 263
def preload_associations!(models, sanitized_association_names, memoized_reflections)
  return unless models.any?

  preloads  = sanitized_association_names + configuration[:preloads].to_a
  Brainstem::Preloader.preload(models, preloads, memoized_reflections)
end
present_fields(model, context, fields, result = {}) click to toggle source

@api protected Uses the fields DSL to output a presented model. @return [Hash] A hash representation of the model.

# File lib/brainstem/presenter.rb, line 306
def present_fields(model, context, fields, result = {})
  fields.each do |name, field|
    case field
      when DSL::HashBlockField
        next if field.executable? && !field.presentable?(model, context)

        # This preserves backwards compatibility
        # In the case of a hash field, the individual attributes will call presentable
        # If none of the individual attributes are presentable we will receive an empty hash
        result[name] = field.run_on(model, context, context[:helper_instance])
      when DSL::ArrayBlockField, DSL::Field
        if field.presentable?(model, context)
          result[name] = field.run_on(model, context, context[:helper_instance])
        end
      else
        raise "Unknown Brainstem Field type encountered: #{field}"
    end
  end
  result
end
presenter_collection() click to toggle source

@api protected Find the global presenter collection for our namespace.

# File lib/brainstem/presenter.rb, line 417
def presenter_collection
  self.class.presenter_collection
end
to_s_except_nil(thing) click to toggle source

@api protected Call to_s on the input unless the input is nil.

# File lib/brainstem/presenter.rb, line 394
def to_s_except_nil(thing)
  thing.nil? ? nil : thing.to_s
end

Private Instance Methods

assemble_primary_key_sort(scope) click to toggle source
# File lib/brainstem/presenter.rb, line 230
def assemble_primary_key_sort(scope)
  table_name = scope.table.name
  primary_key = scope.model.primary_key

  if table_name && primary_key
    Arel.sql("#{scope.connection.quote_table_name(table_name)}.#{scope.connection.quote_column_name(primary_key)} ASC")
  else
    nil
  end
end
format_filter_value(value) click to toggle source

@api private @param [Array, Hash, String, Boolean, nil] value

@return [Array, Hash, String, Boolean, nil]

# File lib/brainstem/presenter.rb, line 171
def format_filter_value(value)
  return value if value.is_a?(Array) || value.is_a?(Hash)
  return nil if value.blank?

  value = value.to_s
  case value
    when 'true', 'TRUE' then true
    when 'false', 'FALSE' then false
    else
      value
  end
end
reflections_for_model(model) click to toggle source

@api private

Returns the reflections for a model's class if the model is not nil.

# File lib/brainstem/presenter.rb, line 130
def reflections_for_model(model)
  model && Brainstem::Presenter.reflections(model.class)
end