I have a search/filter form where the user may select none to many parameters from which to filter records. The filters could include a specific user_id, or company_id, or project_id, etc. Any combination of these parameters could be submitted to filter records by.
What I'm trying to do is create as simple a query as possible and not need to re-query a subset.
I could pull this off by using more logic in the query...but, it seems there should be a rails way.
Thing.where( params[:user_id].present? ? user_id: params[:user_id] : "user_id IS NOT NULL" ).
where( params[:company_id].present? ? company_id: params[:company_id] : "company_id IS NOT NULL" )
What I'm striving for is something cleaner...like:
Thing.where( user_id: params.fetch(:user_id, '*') )
Then, I could chain all the available search params like this:
Thing.where(
user_id: params.fetch(:user_id, '*'),
company_id: params.fetch(:company_id, '*'),
project_id: params.fetch(:project_id, '*')
)
Another approach
Thing.where('') will return all Things. So, I could do something like:
Thing.where( params[:user_id].present? ? { user_id: params[:user_id] } : '' )
But, this doesn't seem like the rails way.
Is there a way to do this? Thanks!
Just create a model that takes the parameters as input and creates a scope:
class ThingFilter
include ActiveModel::Model
include ActiveModel::Attributes
attribute :user_id
attribute :company_id
attribute :project_id
def resolve(scope = Thing.all)
attributes.inject(scope) do |filtered, (attr_name, value)|
if !value.present?
scope.merge(Thing.where.not(attr_name => nil))
else
scope.merge(Thing.where(attr_name => value))
end
end
end
end
class Thing < ApplicationRecord
def self.filter(**kwargs)
ThingFilter.new(**kwargs).resolve(self.where)
end
end
#things = Thing.filter(
params.permit(:user_id, :company_id, :project_id)
)
This is extremely easy to test and lets you add features like validations if needed without making a mess of your controller. You can also bind it to forms.
There's a method called #merge which can be used in this case.
#things = Thing.all
#things = #things.merge(-> { where(user_id: params[:user_id]) }) if params[:user_id].present?
#things = #things.merge(-> { where(company_id: params[:company_id]) }) if params[:company_id].present?
#things = #things.merge(-> { where(project_id: params[:project_id]) }) if params[:project_id].present?
Although it's not the most concise way to do it, it's pretty readable in my opinion.
Found a concise way to do it, but use it according to your opinion on readability.
#things =
params.slice(:user_id, :company_id, :project_id).
reduce(Thing.all) do |relation, (column, value)|
relation.where(column => value)
end
It feels like you may be coding that which the Ransack gem already provides.
It accepts search parameters, such as user_id_eq and company_name_present, and sort parameters, and converts them to the required SQL.
https://github.com/activerecord-hackery/ransack
Related
For example in my Car model i have such fields:
color, price, year
and in form partial i generate form with all this fields. But how to code such logic:
user could enter color and year and i must find with this conditions, user could enter just year or all fields in same time...
And how to write where condition? I could write something like:
if params[:color].present?
car = Car.where(color: params[:color])
end
if params[:color].present? && params[:year].present?
car = Car.where(color: params[:color], year: params[:year])
end
and so over....
But this is very ugly solution, i'm new to rails, and want to know: how is better to solve my problem?
Check out the has_scope gem: https://github.com/plataformatec/has_scope
It really simplifies a lot of this:
class Graduation < ActiveRecord::Base
scope :featured, -> { where(:featured => true) }
scope :by_degree, -> degree { where(:degree => degree) }
scope :by_period, -> started_at, ended_at { where("started_at = ? AND ended_at = ?", started_at, ended_at) }
end
class GraduationsController < ApplicationController
has_scope :featured, :type => :boolean
has_scope :by_degree
has_scope :by_period, :using => [:started_at, :ended_at], :type => :hash
def index
#graduations = apply_scopes(Graduation).all
end
end
Thats it from the controller side
I would turn those into scopes on your Car model:
scope :by_color, lambda { |color| where(:color => color)}
scope :by_year, lambda { |year| where(:year => year)}
and in your controller you would just conditionally chain them like this:
def index
#cars = Car.all
#cars = #cars.by_color(params[:color]) if params[:color].present?
#cars = #cars.by_year(params[:year]) if params[:year].present?
end
user_params = [:color, :year, :price]
cars = self
user_params.each do |p|
cars = cars.where(p: params[p]) if params[p].present?
end
The typical (naive, but simple) way I would do this is with a generic search method in my model, eg.
class Car < ActiveRecord::Base
# Just pass params directly in
def self.search(params)
# By default we return all cars
cars = all
if params[:color].present?
cars = cars.where(color: params[:color])
end
if params[:price1].present? && params[:price2].present?
cars = cars.where('price between ? and ?', params[:price1], params[:price2])
end
# insert more fields here
cars
end
end
You can easily keep chaining wheres onto the query like this, and Rails will just AND them all together in the SQL. Then you can just call it with Car.search(params).
I think you could use params.permit
my_where_params = params.permit(:color, :price, :year).select {|k,v| v.present?}
car = Car.where(my_where_params)
EDIT: I think this only works in rails 4, not sure what version you're using.
EDIT #2 excerpt from site I linked to:
Using permit won't mind if the permitted attribute is missing
params = ActionController::Parameters.new(username: "john", password: "secret")
params.permit(:username, :password, :foobar)
# => { "username"=>"john", "password"=>"secret"}
as you can see, foobar isn't inside the new hash.
EDIT #3 added select block to where_params as it was pointed out in the comments that empty form fields would trigger an empty element to be created in the params hash.
Converting a Rails 2 application to Rails 3, I have to replace the gem searchlogic. Now, using Rails 3.2.8 with the gem Ransack I want to build a search form which uses an existing scope. Example:
class Post < ActiveRecord::Base
scope :year, lambda { |year|
where("posts.date BETWEEN '#{year}-01-01' AND '#{year}-12-31'")
}
end
So far as I know, this can be achieved by defining a custom ransacker. Sadly, I don't find any documentation about this. I tried this in the Postclass:
ransacker :year,
:formatter => proc {|v|
year(v)
}
But this does not work:
Post.ransack(:year_eq => 2012).result.to_sql
=> TypeError: Cannot visit ActiveRecord::Relation
I tried some variations of the ransacker declaration, but none of them work. I Need some help...
UPDATE: The scope above is just on example. I'm looking for a way to use every single existing scope within Ransack. In MetaSearch, the predecessor of Ransack, there is a feature called search_methods for using scopes. Ransack has no support for this out of the box yet.
ransack supports it out of the box after merging https://github.com/activerecord-hackery/ransack/pull/390 . you should declare ransakable_scopes method to add scopes visible for ransack.
From manual
Continuing on from the preceding section, searching by scopes requires defining a whitelist of ransackable_scopes on the model class. The whitelist should be an array of symbols. By default, all class methods (e.g. scopes) are ignored. Scopes will be applied for matching true values, or for given values if the scope accepts a value:
class Employee < ActiveRecord::Base
scope :activated, ->(boolean = true) { where(active: boolean) }
scope :salary_gt, ->(amount) { where('salary > ?', amount) }
# Scopes are just syntactical sugar for class methods, which may also be used:
def self.hired_since(date)
where('start_date >= ?', date)
end
private
def self.ransackable_scopes(auth_object = nil)
if auth_object.try(:admin?)
# allow admin users access to all three methods
%i(activated hired_since salary_gt)
else
# allow other users to search on `activated` and `hired_since` only
%i(activated hired_since)
end
end
end
Employee.ransack({ activated: true, hired_since: '2013-01-01' })
Employee.ransack({ salary_gt: 100_000 }, { auth_object: current_user })
Ransack let's you create custom predicates for this, unfortunately the documentation leaves room for improvement however checkout: https://github.com/ernie/ransack/wiki/Custom-Predicates
Also I believe the problem you're trying to tackle is up on their issue tracker. There's a good discussion going on there: https://github.com/ernie/ransack/issues/34
I wrote a gem called siphon which helps you translate parameters into activerelation scopes. Combining it with ransack can achieves this.
You can read full explanation here. Meanwhile here's the gist of it
The View
= form_for #product_search, url: "/admin/products", method: 'GET' do |f|
= f.label "has_orders"
= f.select :has_orders, [true, false], include_blank: true
-#
-# And the ransack part is right here...
-#
= f.fields_for #product_search.q, as: :q do |ransack|
= ransack.select :category_id_eq, Category.grouped_options
```
ok so now params[:product_search] holds the scopes and params[:product_search][:q] has the ransack goodness. We need to find a way, now, to distribute that data to the form object. So first let ProductSearch swallow it up in the controller:
The Controller
# products_controller.rb
def index
#product_search = ProductSearch.new(params[:product_search])
#products ||= #product_formobject.result.page(params[:page])
end
The Form Object
# product_search.rb
class ProductSearch
include Virtus.model
include ActiveModel::Model
# These are Product.scopes for the siphon part
attribute :has_orders, Boolean
attribute :sort_by, String
# The q attribute is holding the ransack object
attr_accessor :q
def initialize(params = {})
#params = params || {}
super
#q = Product.search( #params.fetch("q") { Hash.new } )
end
# siphon takes self since its the formobject
def siphoned
Siphon::Base.new(Product.scoped).scope( self )
end
# and here we merge everything
def result
Product.scoped.merge(q.result).merge(siphoned)
end
end
There is a search action in RoR that can handle some params e.g.:
params[:name] # can be nil or first_name
params[:age] # can be nil or age
params[:city] # can be nil or country
params[:tag] # can be nil or country
The model name is Person. It also has_many :tags.
When finding persons I need like to AND all the conditions that are present. Of course, it not rational and not DRY.
What I tried to do:
conditions = []
conditions << [ "name like ?", params[:name]+"%" ] if params[:name].present?
conditions << [ "age = ?", params[:age] ] if params[:age].present?
conditions << [ "city = like ?", params[:city]+"%" ] if params[:city].present?
#persons = Person.all(:conditions => conditions )
#What about tags? How do include them if params[:tag].present?
Of course, I want my code to be DRY. Now it's not. Even more, it will cause an exception if params[:age] and params[:name] and params[:city] are not present.
How can I solve? And how do I include tags for persons filtered by tag.name=params[:tag] (if params[:tag].present?) ?
You should do something like this:
lets say filters parameters include this:
filters = {
:name_like => "Grienders",
:age_equal => 15
}
Now you define methods for each
class Person
def search_with_filters(filters)
query = self.scoped
filters.each do |key, values|
query = query.send(key, values)
end
return query
end
def name_like(name)
where("name like ?", name)
end
def age_equal(age)
where(:age => age
end
end
Do you see the method search_with_filters will be the "controlling" method that will take a set of query conditions (name_like, age_equal, etc...) and pass them out to matching method name using the send method, and along with that we also pass the condition which will be the parameter of the method.
The reason why this way is good is because you can scale to any number of conditions (your filter lets say get huge) and also the code is very clean because all you have to do is populate your filters parameter. The method is very readable and very modular and also easy to test
To include the tags condition in your query:
if params[:tag].present?
#persons = Person.all(:conditions => conditions).includes(:tags).where("tags.name = ?", params[:tag])
else
#persons = Person.all(:conditions => conditions)
end
As for there error you are getting when params[:age] .. is not present - This is very strange because it is supposed to return false incase the key is not set in params. Could you please paste the error you are getting?
I would work extensively with scopes for this. Starting with default scope, merge other scopes based on conditions.
Write scopes (or class methods):
class Person << AR::Base
...
NAME_PARTS = ['first_name', 'last_name']
scope :by_name, lambda { |q| where([NAME_PARTS.join(' LIKE :q OR ') + ' LIKE :q', { :q => "%#{q}%" }]) }
scope :by_email, lambda { |q| joins(:account).where(["accounts.email LIKE :q", { :q => "%#{q}%" }]) }
scope :by_age, where(["people.age = ?", q])
scope :tagged, lambda { |q| joins(:tags).where(["tags.name LIKE :q", { :q => "%#{q}%" }]) }
end
Refer:
Scopes in Rails 3
Scopes overhaul section in How Rails 3 Makes Your Life Better
Now, merge the scopes only when the condition is met. As I understand your condition, is the value for a particular search item is nil (like, age is not given), do not search for that scope.
...
def search(object)
interested_fields = ['name', 'age', 'email', 'tags']
criteria = object.attributes.extract!(*interesting_fields) # returns { :age => 20, ... }
scope = object.class.scoped
build(criteria).each do |k, v|
scope = scope.send(k.to_sym, v)
end
scope.all
end
# This method actually builds the search criteria.
# Only keep param which has value and reject the rest.
def build(params)
required = ['name', 'age', 'email', 'tags']
params.delete_if { |k, v| required.include?(k) && v.blank? }
params
end
...
Refer:
extract!
I have to parameters: name and age
def self.search(name, age)
end
If name is not empty/nil, then add it to the search expression.
If age is not empty/nil, then add it to the search expression.
if both are nil, return all.
So far I have:
def self.search(name, age)
if name.nil? && age.nil?
return User.all
end
end
Finding it hard to write this in a elegant way.
def self.search(name, age)
conditions = {}
conditions[:name] = name if name
conditions[:age] = age if age
User.find(:all, :conditions => conditions)
end
I'm not sure what sort of search you're doing but I prefer to handle these sort of things in a scope.
class User < ActiveRecord::Base
scope :by_name, lamba{|name| name.nil?? scoped : where(:name=>name) }
scope :by_age, lamba{|age| age.nil?? scoped : where(:age=>age) }
def self.search(name, age)
User.by_name(name).by_age(age)
end
end
It's a bit more code overall I suppose but it's more reusable and everything is in it's place.
In Rails 3 you can do it like this:
def self.search(name, age)
scope = User
scope.where(:name => name) if name.present?
scope.where(:age => age) if age.present?
scope
end
Note the use of present? rather than nil? to skip empty strings as well as nil.
In the other comment you mentioned wanting to OR these conditions. ActiveRecord does not provide convenient facilities for that; all conditions are AND by default. You will need to construct your own conditions like so:
def self.search(name, age)
scope = User
if name.present? && age.present?
scope.where('name = ? OR age = ?', name, age)
else
scope.where(:name => name) if name.present?
scope.where(:age => age) if age.present?
end
scope
end
I understand that validating uniqueness of a standard, single field like "username" is easy. However, for something that has an unlimited number of inputs like, for example, "Favorite Movies" where a user can add as many favorite movies, is something I can't figure out.
They can choose to add or remove fields via the builder, but how do I ensure that no two or more entries are duplicates?
I think the easiest way to accomplish something like this is to validate the uniqueness of something in a scope. I can't say for sure how it would fit in your scenario since you did not describe you model associations but here is an example of how it could work in a FavoriteMovie model:
class FavoriteMovie < ActiveRecord::Base
belongs_to :user
validates_uniqueness_of :movie_name, :scope => :user_id
end
This makes sure that there can't be two movie names that are the same for one specific user.
It turns out that when using nested attributes, you can only validate what's already in the database and not new duplicate occurrences. So, a validation extension (below) with memory validation is really the only option, unfortunately.
#user.rb
class User
has_many :favorite_movies
validate :validate_unique_movies
def validate_unique_movies
validate_uniqueness_of_in_memory(
favorite_movies, [:name, :user_id], 'Duplicate movie.')
end
end
#lib/extensions.rb
module ActiveRecord
class Base
def validate_uniqueness_of_in_memory(collection, attrs, message)
hashes = collection.inject({}) do |hash, record|
key = attrs.map {|a| record.send(a).to_s }.join
if key.blank? || record.marked_for_destruction?
key = record.object_id
end
hash[key] = record unless hash[key]
hash
end
if collection.length > hashes.length
self.errors.add_to_base(message)
end
end
end
end
A very un-rails like solution to the problem would be to add a unique key constraint on the columns that in combination are required to be unique:
create unique index names_idx on yourtable (id, name);
you could easly check it like:
params[:user][:favourite_movies].sort.uniq == params[:user][:favourite_movies].sort
or in model:
self.favourite_movies.sort.uniq == self.favourite_movies.sort
irb(main):046:0> movies = ['terminator', 'ninja turtles', 'titanic', 'terminator' ].map {|movie| movie.downcase }
=> ["terminator", "ninja turtles", "titanic", "terminator"]
irb(main):047:0> movies.sort.uniq == movies.sort
=> false
You can try to create virtual attribute and check it uniqueness:
def full_name
[first_name, last_name].joun(' ')
end
def full_name=(name)
split = name.split(' ', 2)
self.first_name = split.first
self.last_name = split.last
end
You can check uniqueness on the database level by fix your migration:
CREATE TABLE properties (
namespace CHAR(50),
name CHAR(50),
value VARCHAR(100),
);
execute <<-SQL
ALTER TABLE properties
ADD CONSTRAINT my_constraint UNIQUE (namespace, name)
SQL
Little more modern approach: validates method
validates :movie_name, :uniqueness => {:scope => : user_id}