Search code examples
ruby-on-railsarel

Rails and Arel and Scopes - simplify multiple OR's on the same table/field match


I have a requirement where the user can type in a search box and the Rails api should search any of the customer fields for a possible match, so I started like this and realised this was not such a great solution and seemed quite repetitive for all 5 fields:

scope :filter, -> (term) { where(
  "lower(customers.name) LIKE ? OR 
   lower(customers.email) LIKE ? OR
   lower(customers.business_name) LIKE ? OR
   lower(customers.phone) LIKE ? OR
   lower(customers.doc_id) LIKE ? OR",
  "%#{term.downcase}%", "%{term.downcase}%", "%#{term.downcase}%",
  "%#{term.downcase}%", "%#{term.downcase}%"
) }

So I learned about Arel and tried this instead:

  customers = Customer.arel_table
  scope :filter, -> (term) { Customer.where(
     customers[:name].matches("%#{term.downcase}%")).
  or(customers[:email].matches("%#{term.downcase}%")).
  or(customers[:phone].matches("%#{term.downcase}%")).
  or(customers[:business_name].matches("%#{term.downcase}%").
  or(customers[:doc_id].matches("%#{term.downcase}%"))
  ) }

but that is just as repetitive.

Is there a way to simply either version? I was thinking maybe for Arel I could do this:

scope :filter, -> (term) { Customer.where(
     customers[:name, :email, :phone, :business_name, :doc_id].matches("%#{term.downcase}%")
)  }

UPDATE

Apologies but I forgot to mention - I was trying to keep this simple! - that if there is a simpler solution, it would still need to be a chainable scope, because I am using this filter in a chain of other scopes, like this in the controller:

    if params[:filter].present?
      @cards = current_api_user.account.cards.new_card(:false).search(params.slice(:filter))
    else ...

where 'search' is a concern that simply sends the filter params key/value pair to scopes in the model. For example, here is the cards model scopes (you can see it's filter scope then calls the filter_customer scope, which then calls Customer.filter which is the one the question is about). This might seem complex but it means I have complete composability of all scopes for all these related models:

  scope :new_card, -> value { where(is_new: value) }
  scope :filter_template, -> (term) { Card.where(template_id: Template.filter(term)) }
  scope :filter_customer, -> (term) { Card.where(customer_id: Customer.filter(term)) }
  scope :filter, -> (term) { Card.filter_customer(term).or(Card.filter_template(term)) }

Solution

  • Option 1:

    Build a condition string with many ORs

    fields = ["name", "email", "phone", "business_name", "doc_id"]
    filter = fields.map { |field| "lower(#{field}) LIKE '#{term.downcase}'" }.join(' OR ')
    @customers = Customer.where(filter)
    

    Option 2:

    Concatenate searches using simple conditions

    fields = ["name", "email", "phone", "business_name", "doc_id"]
    @customers = []
    fields.each do |field| 
      filter = "lower(#{field}) LIKE '#{term.downcase}'"
      @customers.concat(Customer.where(filter))
    end
    

    Scope:

    With a small change you can use the first method as a scope

    Class Customer

    scope :filter_customer, -> (term) { Customer.where(Customer.build_filter(term)) }
    
    def self.build_filter term
      fields = ["name", "email", "phone", "business_name", "doc_id"]
      filter = fields.map { |field| "lower(#{field}) LIKE '#{term.downcase}'" }.join(' OR ')
    end
    

    Notes: Your first post was based on Customer and I made all code based on this model. After your update, the answer needs some changes to use in Cards, but it should be trivial.