Running with Ruby

Tag: Ransack

How to use Ransack helpers with MongoDB and Mongoid on Rails 4.0

Ransack is a rewrite of MetaSearch. It allows you to create search forms and sorting for your ActiveRecord data. Somehow I really miss that, when working with Mongoid. To be honest I can even live without the whole Ransack search engine, but I liked the UI helpers for search forms and sorting data.

I’ve really wanted to be able to do something like that:

@users = current_app.users.order(params[:q]).page(current_page)

and

%tr.condensed{:role => "row"}
  %th= sort_link @search, :guid, 'User id'
  %th= sort_link @search, :email, 'Email address'
  %th= sort_link @search, :current_country, 'Country'
  %th= sort_link @search, :current_city, 'City'

with Mongoid documents. It turns out, that if you don’t need really sophisticated stuff, you can use Ransack helpers even with Mongoid. In order to use search/sort UI features, you need @search Ransack model instance. It is based on ActiveRecord data and unfortunately we don’t have any models that would suit our needs. That’s why we need to create a dummy one:

module System
  # In order to use Ransack helpers and engine for non AR models, we need to 
  # initialize @search with AR instance. To prevent searching (this is just a stub)
  # we do it usign ActiveRecord dummy class that is just a blank class
  class ActiveRecord < ActiveRecord::Base

    def self.columns
      @columns ||= []
    end

  end
end

This is enough to use it in controllers and views:

# In order to use Ransack helpers and engine for non AR models, we need to 
# initialize @search with AR instance. To prevent searching (this is just a stub)
# we do it with none
def search
  @search ||= ::System::ActiveRecord.none.search(params[:q])
end

It is a dummy ActiveRecord class, that will always return empty scope (none), but it is more than enough to allow us to work with UI helpers. To handle ordering of Mongoid document collection, we could use following code:

module System
  module Mongoid
      # Order engine used to order mongoid data
      # It is encapsulated in segment module, because we store here all the
      # things that are related to filtering/ordering data
      #
      # Since it returns the resource/scope that was provided, modified
      # accordingly to sorting rules, this class can be used in defined
      # scopes and it will work with scope chainings
    class Orderable
      # @param resource [Mongoid::Criteria] Mongoid class or a subset 
      #   of it (based on Mongoid::Criteria)
      # @param rules [Hash] hash with rules for ordering
      # @return [Mongoid::Criteria] mongoid criteria scope
      # @example
      #   System::Mongoid::Orderable.new(current_app.users, params[:q])
      def initialize(resource, rules = {})
        @rules = rules
        @resource = resource
      end

      # Applies given sort order if there is one.
      # If not, it will do nothing
      # @example Ordered example scope
      #   scope :order, -> order { System::Mongoid::Orderable.new(self, order).apply }
      def apply
        order unless @rules.blank?
        @resource.criteria
      end

      private

      # Sets given order based on order rules (if there are any)
      # or it will do nothing if no valid order rules provided
      def order
        order = "#{@rules[:s]}".split(' ')
        @resource = order.blank? ? @resource : @resource.order_by(order)
      end
    end
  end
end

This can be used to create scopes like that:

class User
  include Mongoid::Document
  include Mongoid::Attributes::Dynamic

  # Scope used to set all the params from ransack order engine
  # @param [Hash, nil] hash with order options or nil if no options
  # @return [Mongoid::Criteria] scoping chain
  # @example
  #   User.order(params[:q]).to_a #=> order results
  scope :order, -> order { System::Mongoid::Orderable.new(self, order).apply }
end

Ransack – no translations for ActiveRecord nested models

Ransack is a great gem. You can consider it as a new better (with sparkles!) version of MetaSearch. Unfortunately, it seems that it is not translating ActiveRecord attributes, when they are nested.
For example:

module System
  class Setting < ActiveRecord::Base
    validates :name,
      presence: true
  end
end

We have a name attribute on a System::Setting model. According to Rails doc translation yaml for such a structure should be similar to this one:

pl:
  activerecord:
    attributes:
      "system/setting":
        name: Nazwa

Unluckily this won’t work (it did with MetaSearch). When you look into Ransack /lib/ransack/translate.rb file, you will find such a piece of code (around line 25):

defaults = base_ancestors.map do |klass|
  :"ransack.attributes.#{klass.model_name.singular}.#{original_name}"
end

You can see, that Ransack is enclosing all attributes in it’s own namespace. I can’t do this, since I have gems that use “original” Rails convention (activerecord.attributes), so I had to replace those lines with:

defaults  = []
base_ancestors.map do |klass|
  defaults << :"activerecord.attributes.#{klass.model_name.i18n_key}.#{original_name}"
  defaults << :"ransack.attributes.#{klass.model_name.singular}.#{original_name}"
end

After that, Ransack is supporting both namespaces. The whole translate file that you should include in your project should look like this:

I18n.load_path += Dir[File.join(File.dirname(__FILE__), 'locale', '*.yml')]

# This is a temporary workaround for this issue:
# https://github.com/ernie/ransack/issues/273
# It allows names to be translated in sort_link @search, :name based on their
# activerecord.attributes scope
module Ransack
  module Translate
    def self.attribute(key, options = {})
      unless context = options.delete(:context)
        raise ArgumentError, "A context is required to translate attributes"
      end

      original_name = key.to_s
      base_class = context.klass
      base_ancestors = base_class.ancestors.select { |x| x.respond_to?(:model_name) }
      predicate = Predicate.detect_from_string(original_name)
      attributes_str = original_name.sub(/_#{predicate}$/, '')
      attribute_names = attributes_str.split(/_and_|_or_/)
      combinator = attributes_str.match(/_and_/) ? :and : :or
      defaults  = []
      base_ancestors.map do |klass|
        defaults << :"activerecord.attributes.#{klass.model_name.i18n_key}.#{original_name}"
        defaults << :"ransack.attributes.#{klass.model_name.singular}.#{original_name}"
      end

      translated_names = attribute_names.map do |attr|
        attribute_name(context, attr, options[:include_associations])
      end

      interpolations = {}
      interpolations[:attributes] = translated_names.join(" #{Translate.word(combinator)} ")

      if predicate
        defaults << "%{attributes} %{predicate}"
        interpolations[:predicate] = Translate.predicate(predicate)
      else
        defaults << "%{attributes}"
      end

      defaults << options.delete(:default) if options[:default]
      options.reverse_merge! :count => 1, :default => defaults
      I18n.translate(defaults.shift, options.merge(interpolations))
    end

  end
end

Copyright © 2018 Running with Ruby

Theme by Anders NorenUp ↑