How do I implement advanced search with operators with pg_search? - ruby-on-rails

I have implemented PgSearch on my Node model like so:
include PgSearch
pg_search_scope :node_search, against: [:name, :user_id, :circa],
using: { tsearch: { any_word: true} },
:associated_against => {
comments: [:message],
user: [:first_name, :last_name, :email],
memberships: [:relation]
}
And in my controller I have this:
if params[:search]
#nodes = Node.node_search(params[:search])
end
Ideally, what I would like to be able to do though, is have someone be able to type in the text representation (a flag) of one of the associations and have the search filter just on that flag.
E.g. say: "name: Bouncing Ball", where the search would take place just on the column called name on the nodes model. Aka...it would look for all the nodes with the name Bouncing Ball and not search other columns or models or even any of the associations.
Naturally, I would like to be able to do searches like:
owner: John Brown (which searches for all nodes whose owner/user first_name and last_name are John Brown), comment: Manhattan (which searches for all nodes that have a comment with the text Manhattan in the copy, and so on.
How do I achieve this with PgSearch?

Have you tried to use a combinations of "Dynamic search scopes" with some controller processing of the search string?
name: Bob, parse out the columns/relationship and the searching value then pass it to a pg_search_scope with a lambda block?

Related

Rails: Possible to send a disabled option as a valid params value?

I have a User, and a Country models, and in User model, it has has_many :countries relationship. The country has 3 attributes -> :id, :name, :is_main
Let's say the countries table is currently populated as follow:
id name is_main
----------------------------------
1 USA true
2 Germany nil
3 France nil
4 England nil
Let's say a user is created that has countries USA, and Germany. So in this case the user.countries.pluck(:id) would return [1,2].
What I would like to achieve is that when editing the user's countries, a dropdown will appear and I am only allowed to add (or remove) countries where 'is_main' attribute is nil. In other words, in the dropdown, the USA country should either be disabled or be hidden completely to be selected. At the same time, the USA should remain in the user.countries after form submission plus any new countries that have been added from update action.
In short:
Original => user.countries.pluck(:id) => [1,2]
In edit form, if I add France to the user, the end result should be user.countries.pluck(:id) => [1,2,3]
I have tried the following:
f.collection_select(:countries, Country.where(is_main: nil), :id, :name, {}, {:multiple => true})
In doing this, the dropdown will display all the countries for me to add except for USA, which is what I desired. But the problem is when I click submit, the params[:country_ids] will be [2, 3]. As a result, after update action, the user.countries.pluck(:id) would become [2,3] instead of desired [1,2,3], effectively removing USA's id.
Is there a way to work around this? I have tried also adding :disabled option in collection_select that disables USA option, but the params[:country_ids] would still be [nil, 2, 3]. Appreciate if anyone could advise me on this.
I've come across this issue on my application. In rails 4+, set include_hidden attribute to false in multi-select selet_tags. Here's how i did it.
= f.input :countries, as: :select, collection: Country.where(is_main: nil).map{|country| [country.id, country.name]}, input_html: { multiple: true, data: { placeholder: "Countries"} }, include_hidden: false
Hope this helps you solve your issue
Actually using your example for multiple selection, your params after submit should have params[:country_ids] = [2,3]. If you are not giving the option to select USA, then it just won't be included.
You can disable options doing this:
// Your select id should be 'user_country_ids', if not change it
// This assumes that your first item will always be USA
$('#user_country_ids option:eq(0)').prop('disabled', true);
Anyway, this JS can be bypassed, so you will need to handle this on your backend too adding a validation for this in your User model:
class User
has_many :countries
validate :main_countries_always_associateds
private
def main_countries_always_associateds
errors.add(:countries, "must include main ones") if Country.where(is_main: true).any?{ |c| !self.countries.include? c }
end
end
EDIT:
If you want to always have main countries inside each user countries, then you can use before_validation callback to override this selection. I recommend to also include the disabled options on the multiple select, so the user is aware of this, the validation may become not necessary.
class User
has_many :countries
before_validation :associate_main_countries
# You should use something like this if you want your dropdown to always show marked main countries
def self.new_custom_user
User.new(countries: Country.where(is_main: true))
end
private
def associate_main_countries
Country.where(is_main: true).each do |c|
self.countries << c if !self.countries.include?(c)
end
end
end

Elasticsearch querying multiple indices while limiting the fields of one index

I have an Autocomplete class with a results method that queries two different models to return data. It partially works great, but with one major issue.
def results
Searchkick.search #query, index_name: [Location, DiveCenter]
end
I need to limit the hits returned from the index DiveCenter because of the way I have the index set up. It's set up this way because a user will either click on a DiveCenter and be taken to the DiveCenter show page OR click on a location and be taken to /search?(city | country)=value.
searchkick searchable: [:name, :city, :state, :country]
def search_data
{
name: name,
city: location.city,
state: location.state,
country: location.country
}
end
So basically, what I need is this:
def results
Searchkick.search #query, index_name: [Location, DiveCenter], fields: ['location.city', 'location.state', 'location.country', 'dive_center.name']
end
Elasticsearch doesn't provide a way to limit results from a single index.
One approach is to filter the results in Ruby.
Another approach is to use multi search to limit the results for dive centers.

elasticsearch sort by score - same field searchable but score different?

I'm trying to sort my ES results by 2 fields: searchable and year.
The mapping in my Rails app:
# mapping
def as_indexed_json(options={})
as_json(only: [:id, :searchable, :year])
end
settings index: { number_of_shards: 5, number_of_replicas: 1 } do
mapping do
indexes :id, index: :not_analyzed
indexes :searchable
indexes :year
end
end
The query:
#records = Wine.search(query: {match: {searchable: {query:params[:search], fuzziness:2, prefix_length:1}}}, sort: {_score: {order: :desc}, year: {order: :desc}}, size:100)
The interesting thing in the query:
sort: {_score: {order: :desc}, year: {order: :desc}}
I think the query is working well with the 2 sort params.
My problem is the score is not the same for 2 documents with the same name (searchable field).
For example, I'm searching for "winery":
You can see a very different score, even if the searchable field is the same. I think the issue is due to the ID field (it's an UUID in fact). Looks like this ID field influences the score.
But in my schema mapping, I wrote that ID should not be analyzed and in my ES query, I ask to search ONLY in "searchable" field, not in ID too.
What did I miss to math the same score for same fields ? (actually, sorting by year after score is not useful cos' scores are different for same fields)
Scores are different, because they are calculated independently for each shard. See here for more info.

Algolia rails sortBy dynamically

I'm building a search using Algolia rails but having difficulties when doing sort by dynamically. For example, user can choose from a dropdown to sort by price asc or price desc.
Here is my model where I defined the indices
class Product < ActiveRecord::Base
include AlgoliaSearch
algoliasearch per_environment: true, if: :publishable? do
attribute :id, :name, :description, :seller_id, :condition, :location, :slug, :status, :city, :state, :stock_quantity,
:shipping_method, :price
attribute :created_at_i do
created_at.to_i
end
attribute :updated_at_i do
updated_at.to_i
end
attribute :price do
price.to_f
end
attributesToIndex ['name', 'unordered(description)', 'seller_id',
'condition', 'location', 'slug', 'created_at', 'updated_at', 'status',
'geo(city)', 'geo(state)']
geoloc :latitude, :longitude
numericAttributesToIndex ["price", "stock_quantity"]
attributesForFaceting ['price', 'condition', 'shipping_method']
end
and the products controller
def index
queries = { hitsPerPage: 5, page: params[:page].to_i, facets: '*',
facetFilters: [
"condition: #{params[:condition]}",
"shipping_method: #{params[:shipping_method]}"
],
numericFilters: [
"price:#{params[:min_price] || 0} to #{params[:max_price] || 999999999999999}"
],
sortBy: ["asc(price)"]
}
if latLng.present?
queries[:aroundLatLng] = latLng
queries[:aroundRadius] = radius
end
#response = Product.search(params[:query],queries)
Algolia returns error "invalid parameter sortBy". I tried to search on algolia documentation but couldn't find any information.
Thanks for helping.
Here is a list of relevant documentation pages from Algolia's website which will help you understand how Algolia's ranking work:
[Getting started] Tweak Ranking and Relevance
The reason our engine is so fast is because each index has its own settings and rankings. This means you are able to create different sets of searchable attributes and attribute ranking relevance by storing your data in multiple indices. This is accomplished using slave indices which are seamlessly synchronized with a master index. Each slave index can then be configured with its own set of business metrics to tune the relevance calculation.
An index has a specific ranking formula that can't be changed. You can however easily overcome this limitation by using slave indices with a different ranking formula.
[Tutorials][Ranking Formula] Tie Breaking algorithm
[FAQ] How does Algolia's tie breaking algorithm work?
These two links will help you understand how the ranking with Algolia work and how you can tweak it. Basically, depending on your use-case, sorting by price before text relevance doesn't really make sense. So, depending on your use case, you might just want to change your customRanking to price, or you might want to add your price attribute at the top of your ranking formula.
[FAQ] What are slave indices and what are their benefits?
This last link explain in more depth what a "slave index" is for Algolia.
[Rails documentation] Multiple sort criteria
[Rails GitHub documentation] Master/Slave
On these last links, you'll find code examples using Algolia's Rails client with multiple slaves. The first one actually showcases exactly your use case: sorting by price.
With all of this in mind, in the end, you're just looking to add these in your model (if you want to use the customRanking way):
add_slave 'Product_by_price_asc', per_environment: true do
customRanking ['asc(price)']
end
add_slave 'Product_by_price_desc', per_environment: true do
customRanking ['desc(price)']
end
Then in your controller, you can query them this way
query_params = { slave: "Product_by_price_#{params[:sort_order]}", hitsPerPage: 5, ... }
Product.search params[:query], query_params
You should probably also do this implementation in the front-end to be able to fully use Algolia's instant search capabilities, see [FAQ] Searching from the front-end or the back-end.

simple_form grouped select association gives "undefined method `map'"

In my application, users describe buildings. A user should be able to specify in which neighborhood a building exists using a grouped select. The models look like:
class Building
include Mongoid::Document
belongs_to :neighborhood
end
class Neighborhood
include Mongoid::Document
field :name, type: String, default: nil
field :borough, type: String, default: nil
field :city, type: String, default: nil
end
Using simple_form, I'm trying to generate a grouped select represent a list of neighborhoods the building might belong to.
= building_form.association :neighborhood, as: :grouped_select, collection: Neighborhood.where(city: city), group_method: :borough
Which ideally creates something like:
Borough #1
Downtown
Uptown
Borough #2
Suburbs
...
However, I get this error:
undefined method `map' for "Borough #1":String
It appears it is calling Neighborhood.borough.map, and because a String doesn't have a map function, it errors out. How do I fix this?
I've struggled with this for sometime, and unfortunately the intuitive 'Rails' magic I was hoping to get from association doesn't seem to exist. It's using the underlying Rails grouped_collection_select, which doesn't seem to handle objects/models very well.
Instead, it appears to handle Arrays much better. According to this documentation, the collection input should be in the form of:
[
['group_name',
[
['item-name','item-value'],
['item2-name','item2-value'],
...(more items)...
]
],
['group2_name',
[
['item3-name','item3-value'],
['item4-name','item4-value'],
...(more items)...
]
],
...(more groups)...
]
MongoDB models don't lend themselves to this format naturally, so I wrote a helper method on my Neighborhood class:
def self.grouped_by_borough(city)
groups = []
Neighborhood.where(city: city).distinct(:borough).each_with_index do |borough, index|
groups << [borough, Neighborhood.where(city: city, borough: borough)]
end
return groups
end
Then my association looks like:
= building_form.association :neighborhood, as: :grouped_select, collection: Neighborhood.grouped_by_borough(city), group_method: :last, option_key_method: :name, option_value_method: :id
This also automatically selects any previously selected neighborhood, which is convenient for 'edit' forms.
If any Rails forms/Mongoid gurus have a cleaner way of handling this, I'd love to hear about it.

Resources