Rails elasticsearch - named scope search - ruby-on-rails

I'm just moving over from the Tire gem to the official elasticsearch Ruby wrapper and am working on implementing better search functionality.
I have a model InventoryItem and a model Store. Store has_many :inventory_items. I have a model scope on Store called local
scope :local, lambda{|user| near([user.latitude, user.longitude], user.distance_preference, :order => :distance)}
I want the search to only return results from this scope so I tried: InventoryItem.local(user).search.... but it searches the entire index, not the scope. After doing some research, it looks like filter's are a good way to achieve this, but I'm unsure how to implement. I'm open to other ways of achieving this as well. My ultimate goal is be able to search a subset of the InventoryItem model based on store location.

Another thing you can do is to send the list of valid ids right to elastic, so it will filter records out by itself and then perform a search on ones that left. We were not doing tests whether it is faster yet, but I think it should, because elastic is a search engine after all.
I'll try to compose an example using you classes + variables and our experience with that:
def search
# retrieve ids you want to perform search within
#store_ids = Store.local(current_user).select(:id).pluck(:id)
# you could also check whether there are any ids available
# if there is none - no need to request elastic to search
#response = InventoryItem.search_by_store_ids('whatever', #store_ids)
end
And a model:
class InventoryItem
# ...
# search elastic only for passed store ids
def self.search_by_store_ids(query, store_ids, options = {})
# use method below
# also you can use it separately when you don't need any id filtering
self.search_all(query, options.deep_merge({
query: {
filtered: {
filter: {
terms: {
store_id: store_ids
}
}
}
}
}))
end
# search elastic for all inventory items
def self.search_all(query, options = {})
self.__elasticsearch__.search(
{
query: {
filtered: {
query: {
# use your fields you want to search, our's was 'text'
match: { text: query },
},
filter: {},
strategy: 'leap_frog_filter_first' # do the filter first
}
}
}.deep_merge(options)
# merge options from self.search_by_store_ids if calling from there
# so the ids filter will be applied
)
end
# ...
end
That way you also have to index store_id.
You can read more about filters here.

I'll leave this answer without accepting until the bounty is over - feel free to add an answer if you think you've found a better solution that one below.
The answer to this ended up being fairly simple after some digging.
Using the named scope:
scope :for_stores, lambda{ |stores| where(store_id: stores.map(&:id)) }
My controller method:
def search
#stores = Store.local(current_user) # get local stores
response = InventoryItem.search 'whatever' # execute the search
#inventory_items = response.records.for_stores(#stores) # call records
end
On elasticsearch responses, you can either call records or results. Calling just results will simply yield the results from the index that you can display etc. Calling records actually pulls the AR records which allows you to chain methods like I did above. Cool! More info in the docs obviously.

Related

Rails : includes doesn't work in model scope

I created a scope in my book model and want to include the author relation. Unfortunately, the relation isn't loaded with the following code:
scope :search, ->(title) {
quoted_title = ActiveRecord::Base.connection.quote_string(title)
includes(:author).where("title % :title", title: title).
order(Arel.sql("similarity(title, '#{quoted_title}') DESC"))
}
I tried several tweaks such as using joins(:author).merge() but the relation is still not loaded. Any idea how to load the relation within a scope? Thanks.
Here is the controller with the method I called through Ajax to render search results:
def search
results_books = Book.search(search_params[:q]).first(5)
results_authors = Author.search(search_params[:q]).first(5)
results = results_books + results_authors
render json: { results: results }, status: :ok
end
For the search function in your scope, if I understand correctly, you are trying to pick out books that matches the searched params according to title field. If so, may I suggest a shorter version like this:
scope :search, lambda { |title|
where('title like ?', "%#{title}%")
}
As for including the associated authors into the json output. We usually use JBuilder when returning JSON objects to the front-end. If you insist in doing it using basic RoR then check out this answer by Substanstial https://stackoverflow.com/a/26800097/9972821
This isn't tested so let me know how well it goes. The rest of the post I shared also touches on JBuilder as the preferred alternative.

Custom ActiveAdmin Filter for Date Range - Complex Logic in Method

I am trying to create a custom ActiveAdmin filter that takes date_range as a parameter. Every solution I've found has been for excessively simple model methods.
Is there a way to pass both parameters into the model ransacker method, and/or at the very least to control the order in which these parameters are passed as well as to know which one is being passed? (end_date vs. start_date -- start_date is passed first, whereas I might be able to work around this is end_date were sent first). Any alternative solution, which would not break all other filters in the application (ie, overwriting activeadmin filters to use scopes - this is one filter out of hundreds in the application) welcome as well.
Thank you!
admin/model.rb
filter :model_method_in, as: :date_range
models/model.rb
ransacker :model_method, :formatter => proc { |start_date, end_date|
Model.complicated_method(start_date, end_date)
} do |parent|
parent.table[:id]
end
...
def method_for_base_queries(end_date)
Model.long_complicated_sql_call_using_end_date
end
def complicated_method(start_date, end_date)
model_instances = method_for_base_queries(end_date)
model_instances.logic_too_complex_for_sql_using_start_date
end
Similar question, but filter/model logic was simple enough for an alternative solution that didn't require both parameters to be passed in: Custom ActiveAdmin filter for Date Range
This might help. Given your index filter
filter :model_method, as: :date_range
you can write the following in your model:
scope :model_method_gteq_datetime, -> (start_date) {
self.where('users.your_date_column >= ?', start_date)
}
scope :model_method_lteq_datetime, -> (end_date) {
# added one day since apparently the '=' is not being counted in the query,
# otherwise it will return 0 results for a query on the same day (as "greater than")
self.where('users.your_date_column <= ?', (Time.parse(end_date) + 1.day).to_date.to_s)
}
def self.ransackable_scopes(auth_object = nil)
[model_method_gteq_datetime, :model_method_lteq_datetime]
end
..._gteq_datetime and ..._lteq_datetime is how Activeadmin interprets the two dates in a custom date_range index filter (see also the corresponding url generated after adding the filter).
I've written a sample query that fits my case (given that users is a model related to the current one), since I don't know the complexity of yours.
I'm using:
Ruby 2.3.1
Rails 5.0.7
Activeadmin 1.3.0

Rails OR query with postgres array

I have a tagging system in rails using postgres' array data type. I'm trying to write a scope that will return any posts that include a tag. So far I have this scope working:
scope :with_tag, ->(tag) { where("tags #> ARRAY[?]", tag) }
I want to extend this scope so that I can query on multiple tags at the same time, ideally something like:
Post.with_tags(['some', 'tags', 'to', 'query'])
Which would return any Post that have one of those tags. I've thought about making a class method to handle iterating over the input array:
def self.with_tags(args)
# start with empty activerecord relation
# want to output AR relation
results = Post.none
args.each do |tag|
results = results.concat(Post.with_tag(tag))
end
results.flatten
end
but this approach smells funny to me because it's creating a new query for each argument. It also doesn't return an ActiveRecord::Relation because of flatten, which I would really like to have as the output.
Can I accomplish what I'm after in a scope with an OR query?
I'm not running the code but I think the && operator does what you want:
scope :with_tags, ->(tags) { where("tags && ARRAY[?]", tags) }

rails "where" statement: How do i ignore blank params

I am pretty new to Rails and I have a feeling I'm approaching this from the wrong angle but here it goes... I have a list page that displays vehicles and i am trying to add filter functionality where the user can filter the results by vehicle_size, manufacturer and/or payment_options.
Using three select form fields the user can set the values of :vehicle_size, :manufacturer and/or :payment_options parameters and submit these values to the controller where i'm using a
#vehicles = Vehicle.order("vehicles.id ASC").where(:visible => true, :vehicle_size => params[:vehicle_size] )
kind of query. this works fine for individual params (the above returns results for the correct vehicle size) but I want to be able to pass in all 3 params without getting no results if one of the parameters is left blank..
Is there a way of doing this without going through the process of writing if statements that define different where statements depending on what params are set? This could become very tedious if I add more filter options.. perhaps some sort of inline if has_key solution to the effect of:
#vehicles = Vehicle.order("vehicles.id ASC").where(:visible => true, if(params.has_key?(:vehicle_size):vehicle_size => params[:vehicle_size], end if(params.has_key?(:manufacturer):manufacturer => params[:manufacturer] end )
You can do:
#vehicles = Vehicle.order('vehicles.id ASC')
if params[:vehicle_size].present?
#vehicles = #vehicles.where(vehicle_size: params[:vehicle_size])
end
Or, you can create scope in your model:
scope :vehicle_size, ->(vehicle_size) { where(vehicle_size: vehicle_size) if vehicle_size.present? }
Or, according to this answer, you can create class method:
def self.vehicle_size(vehicle_size)
if vehicle_size.present?
where(vehicle_size: vehicle_size)
else
scoped # `all` if you use Rails 4
end
end
You call both scope and class method in your controller with, for example:
#vehicles = Vehicle.order('vehicles.id ASC').vehicle_size(params[:vehicle_size])
You can do same thing with remaining parameters respectively.
The has_scope gem applies scope methods to your search queries, and by default it ignores when parameters are empty, it might be worth checking

Pagination Best Practices Ruby

So I am developing a rails app, and I am working on paginating the feed. While I was doing it I wondered if I was doing it the right way because my load times were over 1500ms. My code was:
stories = Story.feed
#stories = Kaminari.paginate_array(stories).page(params[:page]).per(params[:pageSize])
I have a few questions about this:
Should I be paginating Story.feed, or is there some sort of method
that only returns some the stories I need?
Is this load time normal?
What are other things I can be doing to optimize this
(Also, Story.feed returns an array of story objects. The code for that is here:
def self.feed
rawStories = Story.includes([:likes, :viewers, :user, :storyblocks]).all
newFeaturedStories = rawStories.where(:featured => true).where(:updated_at.gte => (Date.today - 3)).desc(:created_at).entries
normalStories = rawStories.not_in(:featured => true, :or => [:updated_at.gte => (Date.today - 3)]).desc(:created_at).entries
newFeaturedStories.entries.concat(normalStories.entries)
end
I am using mongoid and mongodb
The issue is that you get all feeds from db in an array and this takes long time.
I suggest you use the any_of query from this great gem.
From there, do:
def self.feed_stories
newFeaturedStories = Story.where(:featured => true).where(:updated_at.gte => (Date.today - 3.days))
normalStories = Story.not_in(:featured => true, :or => [:updated_at.gte => (Date.today - 3.days)])
Story.includes([:likes, :viewers, :user, :storyblocks]).any_of(newFeaturedStories, normalStories).desc(:created_at)
end
Then paginate this:
selected_stories = Story.feed_stories.per(page_size).page(page)
Dont really understand what are your entries but get them at this moment.
To sum up: the idea s to make a unique paginated db query.
I suspect that when you call Kaminari.paginate_array on an ActiveRecord::Relation, it causes the whole result set to be fetched from DB and loaded in memory similar to calling Model.all.to_a.
To avoid this, I'd first find a way to turn Story.feed into a scope, rather than a class method. Superficially they'll seem the sameā€”the differences are subtle but deep. See Active Record scopes vs class methods.
Next, ditch paginate_array in favor of chain Kaminari's page() and per() scopes.
For example (simplified version of yours):
class Article < ActiveRecord::Base
scope :featured, -> { where(featured: true) }
scope :last_3_days, -> { where(:updated_at.gte => (Date.today - 3)).desc(:created_at) }
scope :feed, -> { featured.last_3_days }
And then paginate simply by going:
Article.feed.per(page_size).page(page)
The biggest advantage of this is that Kaminari can chain into the generated SQL inserting the proper LIMIT and OFFSET clauses thereby reducing the size of the result set returned to only what needs to be displayed, as opposed to returning every matching record.
I think Will Paginate will help you out here -> mislav/will_paginate.
From there you can simply give your controller action .per_page(20) for example and after 20 objects (you can define the objects, see the wiki) there will be pagination

Resources