DRY and Elegant way to represent all search combinations without growing exponentially? - ruby-on-rails

Right now I can search the following
1) leaving_from location
2) going_to location
3) leaving_from &
going_to location
if params[:leaving_from].present? && params[:going_to].present?
#flights = Flight.where(:source => params[:leaving_from]).where(:destination => params[:going_to])
elsif params[:leaving_from].present?
#flights = Flight.where(:source => params[:leaving_from])
elsif params[:going_to].present?
#flights = Flight.where(:destination => params[:going_to])
end
Is there a dry way to represent this code above? Basically its a for search function compromised of 2 drop down search boxes. One for leaving from location and another for going to location. With the option of narrowing it down by both locations or just one location.
It works fine now but it isn't very scalable. If I added more search parameters say price and time, it would grow exponentially in order to be able to represent all the states.
For example if I added price my new combinations would be
1) leaving_from location
2) going_to location
3) leaving_from &
going_to location
4) price
5) leaving_from location & price
6) going_to location & price
7) leaving_from location & going_to location & price
I need help to figure out a better way to represent this, or else it would make my controller incredibly bloated.
EDIT FORM CODE --
=form_tag '/flights', :method => :get
%h4
Leaving From:
=select_tag 'leaving_from', content_tag(:option,'select one...',:value=>"")+options_for_select(#flights_source, 'source'), { :class => 'form-control' }
%h4
Going To:
=select_tag 'going_to', content_tag(:option,'select one...',:value=>"")+options_for_select(#flights_destination, 'destination'), { :class => 'form-control' }
%h4=submit_tag "Search", :name => nil, :class => 'btn btn-success btn-md btn-block'

In place of using leaving_from or going_to use source and destination instead and Move all the required parameters under a key, e.g., this solution will work for any no. of keys
'required' => { 'source' => value, 'destination' => value, 'price' => value }
Now in the controller define this method in private
def get_flights(params)
possible_combination = []
conditions = {}
key_array = params['required'].keys
1.upto(key_array.length) { |i| possible_combination + key_array.combination(i).to_a }
possible_combination.reverse.each do |comb|
if comb.collect{ |key| params['required'][key].present? }.inject(:&)
comb.map { |key| conditions[key] = params['required'][key] }
break
end
end
Flight.where(conditions)
end
Call this method from any action
#flights = get_flights(params)
Hope this works! Its an overall idea to make this thing dynamic, you can refactor the code according to your need!

First things first: your code does not do what you think it does, since there is no way for it to execute the third if (every time the third if is true, the first if is as well). On to your question:
#flights = Flight
#flights = #flights.where(:source => params[:leaving_from]) if params[:leaving_from].present?
#flights = #flights.where(:destination => params[:going_to]) if params[:going_to].present?
Or
conditions = {}
conditions[:source] = params[:leaving_from] if params[:leaving_from].present?
conditions[:destination] = params[:going_to] if params[:going_to].present?
#flights = Flight.where(conditions)

How about using ransack which adds your rails to search function very easily.
You just write below, if you use ransack.
# View (Search Form)
<%= search_form_for #q do |f| %>
From: <%= f.text_field :leaving_from_cont %>
To : <%= f.text_field :going_to_cont %>
Price:
<%= f.text_field :price_gteq %> 〜 <%= f.text_field :price_lteq %>
<%= f.submit %>
<% end %>
# Controller
def index
#q = Flight.ransack(parmas[:q])
#flights = #q.result(distinct: true)
end
If a user don't input any fields, ransack don't use the non-input fields value. It means don't add WHERE conditions in DB.
column_name_cont means contain (Like in DB).
column_name_eq means equal (== in DB).
column_name_gteq means greater than equal (<= in DB).
column_name_lteq means less than equal (>= in DB).
etc...
Also you can sort the search result easily by using sort_link methods of ransack.
Please look in ransack.

I was not able to get #RSB's code to work but I was able to use his example to create a method that did work. I call the below code in my action.
#flights = get_flights(search_params)
The search_params method is as follows:
def search_params
params.permit(:leaving_from, :going_to)
params_hash = {'required' => { 'source' => params[:leaving_from], 'destination' => params[:going_to]}}
end
And finally the get_flights method is:
def get_flights(params)
possible_combination = []
conditions = {}
key_array = params['required'].keys
possible_combination = (possible_combination + key_array.combination(key_array.length).to_a).flatten
possible_combination.each do |comb|
conditions[comb] = params['required'][comb] if params['required'][comb].present?
end
Flight.where(conditions)
end
I am still pretty new to ruby and rails so any feedback or suggestions for improvements would be greatly welcome. Thanks!

Related

Combine multiple scope or where queries with OR

How do I get the arel components in such a ways that I can do something like:
queries = []
queries << MyModel.some_scope.get_the_arel_component
queries << MyModel.some_scope_with_param("Dave").get_the_arel_component
queries << MyModel.where(:something => 'blah').get_the_arel_component
queries << MyModel.some_scope_with_join_and_merge.get_arel_component
# etc ... (may be any number of queries)
# join each query with OR
combined_query = nil
queries.each do |query|
combined_query ||= query
combined_query = combined_query.or(q)
end
# run the query so it just works
MyModel.where(combined_query)
I've encountered some issues with accepted answers of similar questions.
Lets say I have a class like so:
class Patient
has_one :account
scope :older_than, ->(date) { where(arel_table[:dob].lt(date)) }
scope :with_gender, ->(gender) { where(:gender => gender) }
scope :with_name_like, ->(name) { where("lower(name) LIKE ?", name.downcase) }
scope :in_arrears, -> { joins(:account).merge( Account.in_arrears ) }
end
The goal is to combine any scope or where clause with an OR.
One way would be Patient.with_name_like("Susan") | Patient.with_name_like("Dave"). This seems to run each individual query separately instead of combine into a single query. I've ruled this solution out.
Another method that only works in some instances is:
# this fails because `where_values` for the `with_name_like` scope returns a string
sues = Patient.with_name_like("Susan").where_values.reduce(:and)
daves = Patient.with_name_like("Dave").where_values.reduce(:and)
Patient.where(sues.or(daves))
# this works as `where_values` returns an `Arel::Nodes::Equality` object
ages = Patient.older_than(7.years.ago).where_values.reduce(:and)
males = Patients.with_gender('M').where_values.reduce(:and)
Patient.where(ages.or(males))
# this fails as `in_arrears` scope requires a joins
of_age = Patient.older_than(18.years.ago).where_values.reduce(:and)
arrears = Patients.in_arrears.where_values.reduce(:and)
Patient.where(of_age.or(arrears)) # doesn't work as no join on accounts
Patient.join(:account).where(of_age.or(arrears)) # does work as we have our join
To sum up, the issues with ORing queries arise when where is passed a string or the query requires a join.
I'm pretty sure where converts anything passed to it into an arel object, it's just a matter of getting access to the correct pieces and recombining them in the correct way. I just haven't managed to work it out yet.
Preferably the answer will only make use of ActiveRecord and AREL and not a third party library.
Since you're open to using a third party library, how about Ransack?
It has a very robust implementation allowing for all kinds of and and or condition combinations and works well with associated models as well.
For a use case like yours where there are a few predefined queries/scopes that I want the user to be able to select from and run the or combination of them, I use ransack's out of the box implementation and then on the view level, I use javascript to insert hidden fields with values that will result in the structured params hash ransack is expecting in the controller.
All of your scopes are simple to define in a view using ransack helpers. Your code should look like:
Controller
def index
#q = Patient.search(params[:q])
#patients = #q.result(distinct: true)
end
View
<%= search_form_for #q do |f| %>
<%= f.label :older_than %>
<%= f.date_field :dob_lt %>
<%= f.label :with_gender %>
<%= f.text_field :gender_eq %>
<%= f.label :with_name_like %>
<%= f.text_field :name_cont %>
<%= f.label :in_arrears_exceeding %>
<%= f.text_field :accounts_total_due_gte %>
<%= f.submit %>
<% end %>
Also, if you want more control over the anding and oring take a look at the complex search form builder example using ransack.
I had worked on a similar problem in one of my previous projects. The requirement was to find a set of volunteers to scribe matching a set of criteria like email, location, stream of study etc. The solution that worked for me is to define fine-grained scopes and writing up my own query builder like this:
class MatchMaker
# Scopes
# Volunteer => [ * - 'q' is mandatory, # - 'q' is optional, ** - 's', 'e' are mandatory ]
# active - activation_state is 'active'
# scribes - type is 'scribe'
# readers - type is 'reader'
# located - located near (Geocoder)
# *by_name - name like 'q'
# *by_email - email like 'q'
# educated - has education and title is not null
# any_stream - has education stream and is not null
# *streams - has education stream in 'q'
# #stream - has education stream like 'q'
# #education - has education and title like 'q'
# *level - education level (title) is 'q'
# *level_lt - education level (title) is < 'q'
# *level_lteq - education level (title) is <= 'q'
# *marks_lt - has education and marks obtained < 'q'
# *marks_lteq - has education and marks obtained <= 'q'
# *marks_gt - has education and marks obtained > 'q'
# *marks_gteq - has education and marks obtained >= 'q'
# *knows - knows language 'q'
# *reads - knows and reads language 'q'
# *writes - knows and writes language 'q'
# *not_engaged_on - doesn't have any volunteering engagements on 'q'
# **not_engaged_between - doesn't have any volunteering engagements betwee 'q' & 'q'
# #skyped - has skype id and is not null
def search(scope, criteria)
scope = scope.constantize.scoped
criteria, singular = singular(criteria)
singular.each do |k|
scope = scope.send(k.to_sym)
end
if criteria.has_key?(:not_engaged_between)
multi = criteria.select { |k, v| k.eql?(:not_engaged_between) }
criteria.delete(:not_engaged_between)
attrs = multi.values.flatten
scope = scope.send(:not_engaged_between, attrs[0], attrs[1])
end
build(criteria).each do |k, v|
scope = scope.send(k.to_sym, v)
end
scope.includes(:account).limit(Configuration.service_requests['limit']).all
end
def build(params)
rejects = ['utf8', 'authenticity_token', 'action']
required = ['by_name', 'by_email', 'by_mobile', 'streams', 'marks_lt', 'marks_lteq', 'marks_gt',
'marks_gteq', 'knows', 'reads', 'writes', 'not_engaged_on', 'located', 'excluding',
'level', 'level_lt', 'level_lteq']
optional = ['stream', 'education']
params.delete_if { |k, v| rejects.include?(k) }
params.delete_if { |k, v| required.include?(k) && v.blank? }
params.each { |k, v| params.delete(k) if optional.include?(k.to_s) && v.blank? }
params
end
def singular(params)
pattrs = params.dup
singular = ['active', 'scribes', 'readers', 'educated', 'any_stream', 'skyped']
original = []
pattrs.each { |k, v| original << k && pattrs.delete(k) if singular.include?(k.to_s) }
[pattrs, original]
end
end
The form would be something like this:
...
<%= f.input :paper ... %>
<%= f.input :writes ... %>
<%= f.input :exam_date ... %>
<%= f.time_select :start_time, { :combined => true, ... } %>
<%= f.time_select :end_time, { :combined => true, ... } %>
<fieldset>
<legend>Education criteria</legend>
<%= f.input :streams, :as => :check_boxes,
:collection => ...,
:input_html => { :title => 'The stream(s) from which the scribe can be taken' } %>
<%= f.input :education, :as => :select,
:collection => ...,
:input_html => { :class => 'input-large', :title => configatron.scribe_request.labels[:education]}, :label => configatron.scribe_request.labels[:education] %>
<%= f.input :marks_lteq, :label => configatron.scribe_request.labels[:marks_lteq],
:wrapper => :append do %>
<%= f.input_field :marks_lteq, :title => "Marks", :class => 'input-mini' %>
<%= content_tag :span, "%", :class => "add-on" ... %>
<% end %>
</fieldset>
...
And finally
# Start building search criteria
criteria = service_request.attributes
...
# do cleanup of criteria
MatchMaker.new.search('<Klass>', criteria)
This has worked for me very well in the past. Hope this would lead you in the right direction in solving the problems you are facing. All the best.

Move Filter Code into Controller or Helper?

I have application that has a Location and each location can have multiple calendars based on the directional faces assigned to it.
Right now, to filter the correct events that correspond to the calendar, I am rendering them in the
Location show view
Location.html.erb
<%= render partial: #directional_faces %>
_directional_face.html.erb
<%= directional_face.name %>
<% #filtered_event_strips = directional_face.campaigns.event_strips_for_month(#shown_month, #first_day_of_week)%>
<%= raw(event_calendar) %>
This part of the code:
<% #filtered_event_strips = directional_face.campaigns.event_strips_for_month(#shown_month, #first_day_of_week)%>
is what is allowing the concept of multiple calendars to be displayed and then only showing the campaigns that belong to that calendar based on the directional face being shown.
I would like to move this into the controller or helper verses the rendered partial if possible. Or is okay to leave there?
More Details
calendar_helper.rb
def event_calendar_opts
{
:year => #year,
:month => #month,
:event_strips => #filtered_event_strips,
:month_name_text => I18n.localize(#shown_month, :format => "%B %Y"),
:previous_month_text => month_link(#shown_month.prev_month),
:next_month_text => month_link(#shown_month.next_month),
:first_day_of_week => #first_day_of_week,
}
end
Locations Controller #show
def show
#month = (params[:month] || (Time.zone || Time).now.month).to_i
#year = (params[:year] || (Time.zone || Time).now.year).to_i
#shown_month = Date.civil(#year, #month)
#first_day_of_week = 1
end
Definitely put that in the controller. If it's a common thing (used by more than 2 views), I would put that on the DirectionalFace model to make it even easier. Kind of like
def filtered_event_strips(shown_month, first_day_of_week
self.campaigns.event_strips_for_month(shown_month, first_day_of_week)
end
Also, be careful how many instance variables you're passing to your views. You've got 3 in as many lines. There tends to be an easier way to keep the information in the controller/model. Also, make sure you only use # when you really need to. For instance, it doesn't really make sense in this context for #filtered_events_strips to not just be filtered_event_strips.

How to generate Excel file with passing params from AJAX search?

I'm performing AJAX search in my Rails application. Here is code from controller:
def show
#website = Website.find(params[:id])
if (current_user.id != #website.user_id)
redirect_to root_path
flash[:notice] = 'You are not owner!'
end
if params[:report] && params[:report][:start_date] && params[:report][:end_date]
#performance_reports = #website.performance_reports.where("created_at between ? and ?", params[:report][:start_date].to_date, params[:report][:end_date].to_date)
else
#performance_reports = #website.performance_reports
end
but when I'm trying to generate Excel document it alway goes to branch without params, because there are no params in URL.
One man reccomend me to use this post. I tried to implement it, but couldn't.
I don't understand this post enough, I just can't get where data is passing(spreadsheet gem)
Here is code:
def export
#website = Website.last
#data = #website.performance_reports
report = Spreadsheet::Workbook.new
spreadsheet = StringIO.new
contruct_body(spreadsheet, #data)
report.write spreadsheet
send_data spreadsheet.string, :filename => "yourfile.xls", :type => "application/vnd.ms-excel"
end
and it gives me error:
undefined method `contruct_body'
Code from view:
<%= form_tag( url_for, :method => :get, :id => "report") do%>
...show action posted above...
<% end %>
<%= link_to export_path do%>
<b>Export</b>
<% end %>
...working code without AJAX...
<%= link_to url_for(request.parameters.merge({:format => :xls})) do%>
<b>Export</b>
<% end %>
Please tell me where is my mistake or suggest ano
For the first problem, you need to show the view code and the path ajax is taking. Give us more information how the excel is being called.
For the second issue, you need to define that method. Specify how you will populate the spreadsheet with the data. Here is the guide. https://github.com/zdavatz/spreadsheet/blob/master/GUIDE.txt
== Writing is easy
As before, make sure you have Spreadsheet required and the client_encoding
set. Then make a new Workbook:
book = Spreadsheet::Workbook.new
Add a Worksheet and you're good to go:
sheet1 = book.create_worksheet
This will create a Worksheet with the Name "Worksheet1". If you prefer another
name, you may do either of the following:
sheet2 = book.create_worksheet :name => 'My Second Worksheet'
sheet1.name = 'My First Worksheet'
Now, add data to the Worksheet, using either Worksheet#[]=,
Worksheet#update_row, or work directly on Row using any of the Array-Methods
that modify an Array in place:
sheet1.row(0).concat %w{Name Country Acknowlegement}
sheet1[1,0] = 'Japan'
row = sheet1.row(1)
row.push 'Creator of Ruby'
row.unshift 'Yukihiro Matsumoto'
sheet1.row(2).replace [ 'Daniel J. Berger', 'U.S.A.',
'Author of original code for Spreadsheet::Excel' ]
sheet1.row(3).push 'Charles Lowe', 'Author of the ruby-ole Library'
sheet1.row(3).insert 1, 'Unknown'
sheet1.update_row 4, 'Hannes Wyss', 'Switzerland', 'Author'
Add some Formatting for flavour:
sheet1.row(0).height = 18
format = Spreadsheet::Format.new :color => :blue,
:weight => :bold,
:size => 18
sheet1.row(0).default_format = format
bold = Spreadsheet::Format.new :weight => :bold
4.times do |x| sheet1.row(x + 1).set_format(0, bold) end
And finally, write the Excel File:
book.write '/path/to/output/excel-file.xls'

Rails & Sunspot facets and filtering

Pretty much a noobie here, so I appreciate any help someone can give.
I'm trying to add faceting to the search on my site through Sunspot. Ryan just released a great Railscast which got me started: http://railscasts.com/episodes/278-search-with-sunspot. I got that working and was able to add additional facets. My problem is that the facets are independent of each other. If I have 3 facets on 3 different attributes, when I select a facet once I already have on selected, I would like to display only results falling into both of those facests. As of now, it just switches from one facet to the other. I feel like this shouldn't be that difficult, but I can't figure out how to do it.
I did find this tutorial: http://blog.upubly.com/2011/01/06/using-sunspot-in-your-views/ which I think is doing what I want. I tried to get this working but, even when I attempt to make it work with just one facet I don't any results listed. Just the facet name and then nothing else.
Thoughts?
Thank you!!
UPDATE
Here is the code samples of what I am trying to do:
Adjusting the Railscasts code I got this:
In my StylesController:
def index
#search = Style.search do
fulltext params[:search]
facet :departmental, :seasonal, :classifier
with(:departmental, params[:department]) if params[:department].present?
with(:classifier, params[:classification]) if params[:classification].present?
with(:seasonal, params[:season]) if params[:season].present?
end
In my Style Index view (I know I need to condense this)
= form_tag styles_path, :method => :get do
%p
= text_field_tag :search, params[:search]
= submit_tag "Search", :name => nil
#facets
%h4 Departments
%ul
- for row in #search.facet(:departmental).rows
%li
- if params[:department].blank?
= link_to row.value, :department => row.value
(#{row.count})
- else
%strong= row.value
(#{link_to "remove", :department => nil})
%h4 Classifications
%ul
- for row in #search.facet(:classifier).rows
%li
- if params[:classification].blank?
= link_to row.value, :classification => row.value
(#{row.count})
- else
%strong= row.value
(#{link_to "remove", :classification => nil})
%h4 Seasons
%ul
- for row in #search.facet(:seasonal).rows
%li
- if params[:season].blank?
= link_to row.value, :season => row.value
(#{row.count})
- else
%strong= row.value
(#{link_to "remove", :season => nil})
In my Style Model:
searchable do
text :number, :description, :department, :classification, :season
string :departmental
string :classifier
string :seasonal
end
def departmental
self.department
end
def classifier
self.classification
end
def seasonal
self.season
end
And my version of the upubly code, paired down to just try to get the "seasonal" facet working:
I left the the Search Partial, the Search Model and the SearchHelper the same as in the example. I tried to mess with the Helper as my Facets will be pulling text values, not just IDs of other Models, but to no avail. I don't have my various attributes set up as individual Models as I didn't think I needed that functionality, but I am starting to think otherwise.
StylesController:
def index
#title = "All styles"
#search = search = Search.new(params[:search]) # need to set local variable to pass into search method
#search.url = styles_path
#search.facets = [:seasonal]
#solr_search = Style.search do
keywords search.query
with(:seasonal, true)
search.facets.each do |item|
facet(item)
with(:seasonal, params[:season]) if params[:season].present?
end
any_of do
# filter by facets
search.facets.each do |item|
with(item).all_of( params[item].try(:split, "-") ) if params[item].present?
end
end
paginate(:page => params[:page], :per_page => 10)
end
Again, I appreciate the help. Definitely a noob, but really enjoying the process of building this site. Stackoverflow has been a HUGE help for me already, so I owe everybody who posts answers on here a big-time thank you.
I needed the answer to this myself, and seeing as there seems to be nothing else on the web about it, I decided I'd try to figure it out myself.
First I came to the conclusion through logic, that the controller can handle multiple facets and there's no reasons it cannot, I remembered that the best part about ruby is that it is the most human readable code, try to read your first controller and you'll see that it makes sense that it works. I tested this by manually entering in a query string in url, which returned expected results. Therefore, once I figured that out, I knew the issue resided in my view (which made me facepalm because it's fairly obvious now)
Your example is significantly more complex than mine, and my answer might not 100% meet every requirement but I'm pretty sure it's close. Also your code in your model regarding "departmental" etc is a little redundant in my view
Controller
def index
#search = Style.search do
fulltext params[:search]
facet :departmental, :seasonal, :classifier
with(:departmental, params[:department]) if params[:department].present?
with(:classifier, params[:classification]) if params[:classification].present?
with(:seasonal, params[:season]) if params[:season].present?
end
View
%h4 Departments
%ul
- for row in #search.facet(:departmental).rows
%li
- if params[:department].blank?
= link_to row.value, styles_path(
:department => row.value,
:classification => (params[:classification] unless params[:season].blank?),
:season => (params[:season] unless params[:season].blank?))
(#{row.count})
- else
%strong= row.value
= link_to "remove", styles_path(
:department => nil,
:classification => (params[:classification] unless params[:season].blank?),
:season => (params[:season] unless params[:season].blank?))

Passing parameter back to Model to refine Random action

I'm creating an application that'll display a random picture based upon a defined letter in a word.
Images are attached to a Pictures model (containing another "letter" field) using Paperclip, and will be iterated through in an each block.
How would I go about passing the letter back from the each block to the model for random selection.
This is what I've come up with so far, but it's throwing the following error.
undefined method `%' for {:letter=>"e"}:Hash
Model:
def self.random(letter)
if (c = count) != 0
find(:first, :conditions => [:letter => letter], :offset =>rand(c))
end
end
View:
<% #letters.each do |a| %>
<%= Picture.random(a).image(:thumb) %>
<% end %>
Thanks
One problem is your conditions has a syntax error. The hash notation is wrong:
:conditions => [:letter => letter]
should be
:conditions => {:letter => letter}
Also, it seems to me that your random scope will always exclude the first Picture if you don't allow an offset of 0. Besides that, do you really want to return nil if the random number was 0?
Picture.random(a).image(:thumb) would throw "undefined method 'image' for nil:NilClass" exception every time c==0. Can probably just use:
def self.random(letter)
find(:first, :conditions => {:letter => letter}, :offset =>rand(count))
end
EDIT: You'll either need to guarantee that your db has images for all letters, or tell the user no image exists for a given letter.
<% #letters.each do |a| %>
<% if pic = Picture.random(a).image(:thumb) %>
<%= pic.image(:thumb) %>
<% else %>
No image available for <%= a %>
<% end %>
<% end %>
Or the like...
EDIT: Actually I don't think your offset strategy will work. One other approach would be to return the set of images available for the given letter and randomly select from that collection, something like:
def self.random(letter)
pics = find(:all, :conditions => {:letter => letter})
pics[rand(pics.size)] if !pics.blank?
end

Resources