Rails Sunspot gem: Usings facets with multiple model site-wide searches - ruby-on-rails

I'm trying to implement a sitewide search through the powerful Sunspot gem for Rails. This involves a search across multiple, very different models at once. What I WANT to do is use the faceting feature to allow the user to filter their search results on each model, or by default view all on the same page, interspersed with each other ordered by the :boost qualifier. Combining the faceting code from the Sunspot Railscast with the multiple model searching code from another Stackoverflow question (a variation upon the 'Multiple Type' code from the Sunspot documentation) gave me a solution that I'd think would work, but doesn't.
The multiple method search succeeds, but the facets always turn up null. My basic approach is to provide a virtual attribute on each model by the same name, :search_class, that is just the model's class name rendered into a string. I then try and use that as a facet. However, in the view logic the results of the facet (#search.facet(:search_class).rows) is always an empty array, including when #search.results returns many different models in the same query, despite each returned instance having a perfectly accessible Instance.search_class attribute.
I'm using Rails 3.1.0 and sunspot-rails 1.2.1.
What should I do to make this faceting code work?
Controller:
#searches_controller.rb
class SearchesController < ApplicationController
def show
#search = search(params[:q])
#results = #search.results
end
protected
def search(q)
Sunspot.search Foo, Bar, CarlSagan do
keywords q
#provide faceting for "search class", a field representing a pretty version of the model name
facet(:search_class)
with(:search_class, params[:class]) if params[:class].present?
paginate(:page => params[:page], :per_page => 30)
end
end
end
Models:
#Foo.rb
class Foo < ActiveRecord::Base
searchable do
text :full_name, :boost => 5
text :about, :boost => 2
#string for faceting
string :search_class
end
#get model name, and, if 2+ words, make pretty
def search_class
self.class.name#.underscore.humanize.split(" ").each{|word| word.capitalize!}.join(" ")
end
end
#Bar.rb
class Bar < ActiveRecord::Base
searchable do
text :full_name, :boost => 5
text :about, :boost => 2
#string for faceting
string :search_class
end
#get model name, and, if 2+ words, make pretty
def search_class
self.class.name.underscore.humanize.split(" ").each{|word| word.capitalize!}.join(" ")
end
end
#CarlSagan.rb
class CarlSagan < ActiveRecord::Base
searchable do
text :full_name, :boost => 5
text :about, :boost => 2
#string for faceting
string :search_class
end
#get model name, and, if 2+ words, make pretty
def search_class
self.class.name#.underscore.humanize.split(" ").each{|word| word.capitalize!}.join(" ")
end
end
View:
#searches/show.html.erb
<div id="search_results">
<% if #results.present? %> # If results exist, display them
# If Railscasts-style facets are found, display and allow for filtering through params[:class]
<% if #search.facet(:search_class).rows.count > 0 %>
<div id="search_facets">
<h3>Found:</h3>
<ul>
<% for row in #search.facet(:search_class).rows %>
<li>
<% if params[:class].blank? %>
<%= row.count %> <%= link_to row.value, :class => row.value %>
<% else %>
<strong><%= row.value %></strong> (<%= link_to "remove", :class => nil %>)
<% end %>
</li>
<% end %>
</ul>
</div>
<% end %>
<% #results.each do |s| %>
<div id="search_result">
<% if s.class.name=="Foo"%>
<h5>Foo</h5>
<p><%= link_to s.name, foo_path(s) %></p>
<% elsif s.class.name=="Bar"%>
<h5>Bar</h5>
<p><%= link_to s.name, bar_path(s) %></p>
<% elsif s.class.name=="CarlSagan"%>
<h5>I LOVE YOU CARL SAGAN!</h5>
<p><%= link_to s.name, carl_sagan_path(s.user) %></p>
<% end %>
</div>
<% end %>
<% else %>
<p>Your search returned no results.</p>
<% end %>
</div>

This
Sunspot.search(Foo, Bar){with(:about, 'a'); facet(:name)}
translates to the following in Solr
INFO: [] webapp=/solr path=/select params={facet=true&start=0&q=*:*&f.name_s.facet.mincount=1&facet.field=name_s&wt=ruby&fq=type:(Foo+OR+Bar)&fq=about_s:a&rows=30} hits=1 status=0 QTime=1
You can find the exact Solr query in the solr/log/ solr_production.log file
if you notice facet(:name) translated to f.name_s.facet and not f.foo.facet and f.bar.facet. That is why it did not work as you expected.
The following will work but that needs one to create 3 dummy methods in each model. The idea is you need a separate facet line for each of the types.
Sunspot.search Foo, Bar, CarlSagan do
keywords q
#provide faceting for "search class", a field representing a pretty version of the model name
facet(:foo)
facet(:bar)
facet(:carlsagan)
with(:search_class, params[:class]) if params[:class].present?
paginate(:page => params[:page], :per_page => 30)
end
Again, it is always better to look at the actual SOLR query log to debug the search issues. Sunspot makes many things magical but it has its limitations ;-)

Related

How to chain search params and use them as objects?

In my project I have a department model. I want to add employees to the department by using a search. I want to add the result of the search to a list, then submit the list and add all searched employees in one go at the end, all in the same view.
Search function in departments_controller
def add_employees
employees = Employee.all
#searched_employee = Employee.where('name LIKE ?', "#{params[:search_by_name]}")
#searched_employee.each do |employee|
#searched_employee_name = employee.name
end
end
add_employees-view:
h1 Add employees
= form_for #department, :url => add_employees_path(:param1 => #searched_employee_name, :param2 => request.query_parameters), method: :post do
= label_tag :search_by_name
br
= search_field_tag :search_by_name, params[:name]
= submit_tag "Search"
= form_for #department, :url => add_employee_path, html: {method: "post"} do |f|
- if params[:search_by_name].present?
- #searched_employee.each do |employee|
li = employee.name
br
table
h5 Employees
thead
tr Name
tr Email
tbody
- #searched_employee.each do |employee|
tr
td = employee.name
td = request.query_parameters
Single search works fine, so I hoped to add a second param which stores the first request to be passed on for the next search and so forth.
Now I am stuck with splitting up the long query string into its unique search results and their objects, as to add them to a list where I can then work further with them (checkboxes etc).
Request.query_parameters is nested, but does not react to dig, because it says it is a string.
Any ideas on how to approach this or maybe a better solution, without the use of additional gems?
Here is how I would solve it if I had to do it without JS.
Create an M-2-M association with a join model between deparments and employees. Let departments accept nested attributes for the join model:
class Department < ApplicationRecord
has_many :positions
has_many :employees, through: :positions
accepts_nested_attributes_for :positions, reject_if: :reject_position?
private
def reject_position?(attributes)
!ActiveModel::Type::Boolean.new.cast(attributes['_keep'])
end
end
class Employee < ApplicationRecord
has_many :departments
has_many :positions, through: :departments
end
# rails g model position employee:belongs_to department:belongs_to
class Position < ApplicationRecord
belongs_to :employee
belongs_to :department
attribute :_keep, :boolean
end
Setup the routes:
# config/routes.rb
Rails.application.routes.draw do
# ...
# #todo merge this with your existing routes
resources :departments, only: [] do
resources :employees, only: [], module: :departments do
collection do
get :search
patch '/',
action: :update_collection,
as: :update
end
end
end
end
Now lets create the search form:
# /app/views/departments/employees/search.rb
<%= form_with(
url: search_department_employees_path(#department),
local: true,
method: :get
) do |form| %>
<div class="field">
<%= form.label :search_by_name %>
<%= form.text_field :search_by_name %>
</div>
<% #results&.each do |employee| %>
<%= hidden_field_tag('stored_employee_ids[]', employee.id) %>
<% end %>
<%= form.submit("Search") %>
<% end %>
Note that we are using GET instead of POST. Since this action is idempotent (it does not actually alter anything) you can use GET.
Note <%= hidden_field_tag('stored_employee_ids[]', employee.id) %>. Rack will merge any pairs where the key ends with [] into an array.
Now lets setup the controller:
module Departments
# Controller that handles employees on a per department level
class EmployeesController < ApplicationController
before_action :set_department
# Search employees by name
# This route is nested in the department since we want to exclude employees
# that belong to the department
# GET /departments/1/employees?search_by_name=john
def search
#search_term = params[:search_by_name]
#stored_ids = params[:stored_employee_ids]
if #search_term.present?
#results = not_employed.where('employees.name LIKE ?', #search_term)
end
# merge the search results with the "stored" employee ids we are passing along
if #stored_ids.present?
#results = not_employed.or(Employee.where(id: #stored_ids))
end
#positions = (#results||[]).map do |employee|
Position.new(employee: employee)
end
end
private
def not_employed
#results ||= Employee.where.not(id: #department.employees)
end
def set_department
#department = Department.find(params[:department_id])
end
end
end
This just creates a form that "loops back on itself" and just keeps adding more ids to the query string - without all that hackery.
Now lets create a second form where we actually do something with the search results as a partial:
# app/views/departments/employees/_update_collection_form.html.erb
<%= form_with(
model: #department,
url: update_department_employees_path(#department),
local: true,
method: :patch
) do |form |%>
<legend>Add to <%= form.object.name %></legend>
<table>
<thead>
<tr>
<td>Add?</td>
<td>Name</td>
<tr>
</thead>
<tbody>
<%= form.fields_for(:positions, #positions) do |p_fields| %>
<tr>
<td>
<%= p_fields.label :_keep, class: 'aria-hidden' %>
<%= p_fields.check_box :_keep %>
</td>
<td>
<%= p_fields.object.employee.name %>
<%= p_fields.hidden_field :employee_id %>
</td>
</tr>
<% end %>
</tbody>
</table>
<%= form.submit 'Add employees to department' %>
<% end %>
form.fields_for(:positions, #positions) loops through the array and creates inputs for each position.
And render the partial in app/views/departments/employees/search.html.erb:
# ...
<%= render partial: 'update_collection_form' if #positions.any? %>
You should not nest this form inside another form. That will result in invalid HTML and will not work properly.
Unlike your solution I'm not cramming everything and the bathtub into a single endoint. This form sends a PATCH request to /departments/1/employees. Using PATCH on an entire collection like this is somewhat rare as we usually just use it for individual members. But here we really are adding a bunch of stuff to the collection itself.
Now lets add the action to the controller:
module Departments
# Controller that handles employees on a per department level
class EmployeesController < ApplicationController
# ...
# Adds a bunch of employees to a department
# PATCH /departments/:department_id/employees
def update_collection
if #department.update(nested_attributes)
redirect_to action: :search,
flash: 'Employees added'
else
#postions = #department.positions.select(&:new_record?)
render :search,
flash: 'Some employees could not be added'
end
end
private
# ...
def update_collection_attributes
params.require(:department)
.permit(
positions_attributes: [
:keep,
:employee_id
]
)
end
end
end
There is almost nothing to it since accepts_nested_attributes is doing all the work on the controller layer.
I'll leave it up to you to convert this ERB to Slim or Haml and adapt it to your existing code base.
So, if anyone faces a similar problem, what I cam up with is this:
- Hash[CGI::parse(request.query_string).map{|k,v| [k,v]}].values.each do |value|
- valuename = value[0]
- employees = Employee.all
- found_employees = employees.where('name LIKE ?', "#{valuename}")
- found_employees.each do |employee|
tr
td = employee.name
First line: gets the whole query string, parses over it to look for the parameters and puts them in a hash, then reads out the key value pairs, which then are arrays. Those arrays' values are then iterated over.
Second line: since the values are still in an array, I ask the arrays to just put out the pure values with value[0], and assign them to valuename.
Folowing lines: Just querying the database to find all employee-names that match a valuename, receive objects so I can further work on them.

How to extract grouped results from single model/table and provide to the view

I have a model called Language, with just two columns: language and link, and would like to be able to loop through each link for each language and display in the view. i.e. (obviously this isn't code, it's just to illustrate the desired pattern)
Language 1
Link 1
Link 2
Link 3
Language 2
Link 1
Language 3
Link 1
Link 2
Language 4
etc
What is the 'rails way' of extracting this data, and then presenting in the view? (note: I would know how to do this easily if the data were in two different models, but it isn't)
So, a Railsy way would be to use the following in your controller:
#languages = Language.all.group_by(&:language)
This will give you a hash of languages grouped by the (erm...) language's language (<- perhaps rename the column to name to avoid this ambiguity?):
# { 'English' => [language_1, language_2, etc...],
# 'French' => [language_3, language_4],
# etc... }
And then this in your view:
<% #languages.each do |language_name, languages| %>
<h1>
<%= language_name %>
</h1>
<% languages.each do |language| %>
<p>
<%= language.link %>
</p>
<% end %>
<% end %>
Obviously the HTML tags can be whatever you'd like, though I hope that gives a useful example.
However, there's a caveat here - as your database grows, this might not prove an efficient way of working. You'll likely be better off setting up a separate model for links, with a one-to-many relationship between languages and links.
For example:
# langage.rb
has_many :links
# link.rb
belongs_to :language
# your controller
#languages = Language.includes(:links)
And then something like the following in the view:
<% #languages.each do |language| %>
<h1>
<%= language.language %>
</h1>
<% language.links.each do |link| %>
<p>
<%= link.url %>
</p>
<% end %>
<% end %>

Rails Form element to convey two values

I have a form that takes bookings for an event for people. The form displays events vertically, and a name & checkbox for each of the possible people next to each event.
How should I best convey the two pieces of information that i need per checkbox? that is, the event_id and the person_id
I'm not totally sure wether I got you right. This is the model I assume you're talking about:
# event.rb
class Event
has_many :people
scope :possible_people, -> { #whatever .. }
end
# person.rb
class Person
belongs_to :event
end
# events_controller.rb
class EventsController
def index
#events = Event.all
end
end
And this might be a possible solution to change an events relation to people:
# index.html.erb
<ul id="events">
<% #events.each do |event| %>
<li class="event">
<%= form_for #event do |form| %>
<% event.possible_people.each do |person| %>
<%= check_box_tag "event[person_ids][]", person.id, #event.people.include?(person) %>
<% end %>
<%= f.submit_tag 'Save Event' %>
<% end %>
</li>
<% end %>
</ul>
The important part is <%= check_box_tag "event[person_ids][]", person.id, #event.people.include?(person) %> where you actually change the the relation of a specific person to the event.
Good luck ;)
Well, you can try out something like below line, I am assuming you have a multiselect checkboxes and i am passing a Hash of event_id => plate_id as value to checkbox.
<%= check_box_tag 'booking[event_people_ids][]', {booking.event_id => booking.plate_id} %>
You will get the value in params as:
booking => {event_people_ids =>["{"72"=>"3"}}
I ended up doing this:
<%= check_box_tag "booking[]", "#{event.id}-#{person.id}" %>
and then in then to process them:
params[:booking].each do |booking|
booking = booking.split('-')
a = {
:booking_id => #booking.id,
:person_id => booking[1],
:event_id => booking[0]
}
Appointment.create(a)
end
I was hoping for a more railish way to do it but this works.

Simple Search Form in Rails 3 App not Displaying

I ran through railscast #37 and can't seem to even get my search form to display on my index page. Any ideas whats wrong with my code? thanks in advance! any help is greatly appreciated.
Here is the index page where the form is located:
<h1>All users</h1>
<% form_tag users_path, :method => 'get' do %>
<p>
<%= text_field_tag :search, params[:search] %>
<%= submit_tag "Search", :name => nil %>
</p>
<% end %>
Heres the controller:
class UsersController < ApplicationController
...
def index
#title = "All users"
#users = User.paginate(:page => params[:page])
#usersearch = User.search(params[:search])
end
...
Lastly, the user.rb file:
...
def self.search(search)
if search
where('name LIKE ?', "%#{search}%")
else
all
end
end
I didn't look further, but there must be an equal sign in form_tag in the recent Rails versions. You should also get an error message on that in development mode.
Try <%= form_tag users_path, :method => 'get' do %>
I saw your previous question. It was deleted while I was submitting my answer. This looks similar to that question, so here is my answer to the previous question and this one.
I searched the users based on their country. So this is what I did for that:
First I created a new column country based on what I'm going to search. The command is
$ rails generate migration add_country_to_users country:string
$ rake db:migrate
Add this string to the attr_accessible in the user model as follows:
class User < ActiveRecord::Base
attr_accessor :password
attr_accessible :name, :email, :password, :password_confirmation, :country
validates_presence_of :country
..........
attr_accessible lets you do a mass assignment. Don't forget to add the new column here.
Once this is done you are now ready to write a controller function. I named mine network because I want to display users based on country. My search word is going to be country and I will display all the users belonging to one particular country. The function definition is as follows:
class UsersController < ApplicationController
def network
#title = "All users"
#users = User.find_all_by_country(User.find(params[:id]).country)
end
end
For your question if you want to display users by name make it
User.find_all_by_name(User.find(params[:id]).name)
or something like that based on our search word.
Now coming to the display. I created a new page under views/users/ as network.html.erb as I want to display a network of users belonging to a country. First have one form from where you will give the input, i.e where you invoke the search. For mine I have a link in the header of my form. The link is as follows:
<li><%= link_to "Network", network_user_path(current_user) %></li>
Once user clicks this the following form will be displayed:
<h1>All users</h1>
<ul class="users">
<% #users.each do |user| %>
<li>
<%= gravatar_for user, :size => 30 %>
<%= link_to user.name, user %>
</li>
<% end %>
</ul>
So far, so good. Now the final step is to connect them by adding them in the routes.rb file. Add the following in that file:
MyApp::Application.routes.draw do
resources :users do
member do
get :following, :followers, :network, :news
end
end
end
So this is what I did for my application and it worked. I hope it helps.

How can I display a list of three different Models sortable by the same :attribute in rails?

I have a Campaign model which has_many Calls, Emails, and Letters.
For now, these are each a separate Model with different controllers and actions (although I would like to start to think of ways to collapse them once the models and actions stabilize).
They do share two attributes at least: :days and :title
I would like a way to represent all the Calls, Emails, and Letters that belong_to a specific Campaign as a sortable collection (sortable by :days), in a way that outputs the model name and the path_to() for each.
For example (I know the below is not correct, but it represents the kind of output/format I've been trying to do:
#campaign_events.each do |campaign_event|
<%= campaign_event.model_name %>
<%= link_to campaign_event.title, #{model_name}_path(campaign_event) %>
end
Thanks so much. BTW, if this matters, I would then want to make the :days attribute editable_in_place.
Here is what I've got working, but want some additional insights
module CampaignsHelper
def campaign_events
return (#campaign.calls + #campaign.emails + #campaign.letters).sort{|a,b| a.days <=> b.days}
end
end
In the VIEW:
<% #campaign_events = campaign_events %>
<% #campaign_events.each do |campaign_event| %>
<% model_name = campaign_event.class.name.tableize.singularize %>
<p>
<%= link_to campaign_event.title, send("#{model_name}_path", campaign_event) %>
<%= campaign_event.days %>
</p>
<% end %>
Like this?
# controller
#campaign = Campaign.find(params[:id])
#campaign_events = (#campaign.calls + #campaign.emails + #campaign.letters).sort{|a,b| a.days <=> b.days}
# view
#campaign_events.each do |campaign_event|
<%= campaign_event.model_name %>
<%= link_to campaign_event.title, #{model_name}_path(campaign_event) %>
end
In controller you find all campaign events and sort it by days field

Resources