4

I'm working on a extended search feature for my webpage.

I looked at ransack, however it's lacking some functionalities I need, makes the url-query string very long and has some bugs (reported).

Thus I started to implement my own hack.

First I want to present my idea, afterwards I want to ask kindly how to fix my issue and in the end if there are other ways to improve this.

The idea:

A model defines something like this (additionally, the model is inside an engine):

module EngineName
  class Post < ActiveRecord::Base
    search_for :name, :as => :string do |b, q|
      b.where{name =~ "%#{q}%"}
    end
  end
end

:name is to define the query-param to use e.g. this would be ?q[name]=something I know that this is not fully generic like ransack, but well...

:as is to build up the correct form-tag. :string would be for text_field, :integer for number_field and so on. I want to extend it further to implement auto-generating of collections for associations etc.

Now the block is a simple scope to use. I run into several shortcomings with ransack when building up complex queries (like with count() etc.). Now I can specify my own optimized query in squeel.

I extended ActiveRecord::Base to set up the logic (the global one, not inside the engine. I want to use it everywhere). I defined a scope :search so I can use Model.search(param[q]) like in ransack. Also I tried to keep a list of keys which are "searchable" defined by the search_for calls.

class ActiveRecord::Base
@@searchable_attributes = Hash.new({})

def self.search_for(name, *opts, &search_scope)
  return unless search_scope

  @@searchable_attributes[name] = {
    :type => opts[:as],
    :condition => search_scope
  }

  unless @@searchable_attributes.has_key? :nil
    @@searchable_attributes[:nil] = Proc.new { scoped }
  end
end

scope :search, lambda {|q|
  next unless q.kind_of?(Hash)

  base = @@searchable_attributes[:nil].call
  q.each do |key, search|
    next unless base.class.searchable_attributes.has_key?(key)
    base = @@searchable_attributes[key][:condition].call(base, search)
  end
  base
}
end

Now the issues:

It has mostly to do with inheritance of the classes. But even after reading and trying 3, 4 it does not worked.

Please take a look at the second line in the scope :search.

There I'm calling the simple Proc I definied above which only includes "scoped" This is to get arround the issue that self returns "ActiveRecord::Base" and not the model itself like "Post" or "Comment".

It's because the scope is called on the Base class on inheritance, however I did not find anything to fix this.

As search_for is called on the model itself (e.g. Post) the scope-model returned there is "the right one".

Does anyone know how to circumvent this?

The next question would be, how to store the list of "searchable" scopes. I used @@variables. But as they are shared within every subclass, this would be a no-go. However, it needs to be static as the search_for is called without initialize a instance (isn't it?)

Last but not least, it is somekind horrible to always specify the base-model to use on every scope so that I can chain them together.

Is there any other possibilities to improve this?

Community
  • 1
  • 1
Noxx
  • 354
  • 1
  • 19

2 Answers2

2

Ok, it seems I got it finally myself my putting several other answers from other questions together.

Model:

module EngineName
  class Post < ActiveRecord::Base
    searchable

    search_for :name, :as => :string do |b, q|
     b.where{name =~ "%#{q}%"}
    end
  end
end

My "Plugin" currently as an initializer:

class ActiveRecord::Base
  def self.searchable
    include Searchable
  end
end

module Searchable
  def self.included(base)
    base.class_eval {

      @@searchable_attributes = Hash.new({})

      def self.search_for(name, opts)
        return unless block_given?

        @@searchable_attributes[name] = {
          :type => opts[:as],
          :condition => Proc.new
        }
      end


      # Named scopes
      scope :search, lambda {|q|
        next unless q.kind_of?(Hash)

        base = self.scoped
        q.each do |key, search|
          key = key.to_sym
          next unless @@searchable_attributes.has_key?(key)
          base = @@searchable_attributes[key][:condition].call(base, search)
        end
        base
      }
    }
  end
end

Hope it'll help some others working on the same problem.

Noxx
  • 354
  • 1
  • 19
  • Meanwhile, I leave the bounty/correct answer open. Maybe someone comes up with another much better solution – Noxx Feb 09 '13 at 20:16
  • Don't know if I understand the question, but if one issue is the "shared" class variables; have you tried class instance variables (or what they are called)? E.g. `class << self; attr_accessor :searchable_attributes; end` (only necessary in the parent class), then use them internally as `@searchable_attributes` in the class, or in a instance method as `self.class.searchable_attributes`. If they only are for internal use in the class you can skip the `attr_accessor`. – 244an Feb 16 '13 at 06:22
1

Rails provides a helper for class_attribute. This provides inheritable class attributes, but allows subclassess to "change their own value and it will not impact parent class". However a hash which is mutated using []= for example would effect the parent, so you can ensure that a new copy is made when subclassing using rubys inherited method

Therefore you could declare and initialise on the base class like so:

module Searchable
  extend ActiveSupport::Concern

  included do
    class_attribute :searchable_attributes
  end

  module ClassMethods
    def inherited(subclass)
      subclass.searchable_attributes = Hash.new({})
    end

    def search_for(name,opts)
      return unless block_given?

      searchable_attributes[name] = {
        :type => opts[:as],
        :condition => Proc.new
      }
    end
  end
end

Note that I used ActiveSupport::Concern to gain the neater syntax for defining stuff directly on the class and also mixing in class methods. Then you can simply add this to active record base:

ActiveRecord::Base.send(:include, Searchable)

now any classes get their own attributes hash:

class Post < ActiveRecord::Base
  search_for :name, :as => :string do |b, q|
    b.where{name =~ "%#{q}%"}
  end
end
loz
  • 117
  • 2
  • also, note that you want to be careful about stuff coming from the query strings, so use the ? interpolation syntax to ensure that stuff is not suceptable to injection: b.where("name LIKE ?", "%#{q}%") – loz Feb 10 '13 at 21:52
  • as squeel-syntax is used, this is not applicable. Squeel escapes automatically in this case. Thanks for the ActiveSupport::Concern and class_attribute hint, it makes a bit more cleaner, but you left the :search-scope out for a full answer. – Noxx Feb 13 '13 at 08:50