Description
I have just migrated our application from searchkick to meilisearch however meilisearch doesn't have a way I can search for single term across multiple indexes or models like searchkick does.
Basic example
I want to to be able to search my term on at least one model
example
Meilisearch.search(term, models: [...index_names])
Here is a workaround using parallel gem
# search.rb
module Queries
class Search < BaseQuery
include SearchHelper
type [Types::SearchResultsType], null: true
argument :query, String, required: true
argument :models, [String], required: true, default_value: ['AppUser']
def resolve(**args)
search(args)
end
end
end
# search_helper.rb
module SearchHelper
SEARCH_MODELS = %w[AppUser Market Organisation]
def search(args)
raise Errors::SearchError::BlankQueryError if args[:query].blank?
raise Errors::SearchError::UnpermittedQueryError if args[:query] == '*'
models = args[:models].map { |m| m.tr(' ', '').camelize }
if models.difference(SEARCH_MODELS).any?
raise Errors::SearchError::UnknownSearchModelError
end
Parallel.flat_map(models, in_threads: models.size) do |m|
m.constantize.search(args[:query])
end
end
end
# search_results_type
module Types
class SearchResultsType < Types::BaseUnion
description 'Models which may be searched on'
possible_types(
Types::AppUserType,
Types::MarketType,
Types::OrganisationType,
)
def self.resolve_type(object, context)
if object.is_a?(AppUser)
Types::AppUserType
elsif object.is_a?(Market)
Types::MarketType
else
Types::OrganisationType
end
end
end
end
Graphql query
{
search(query: "Gh", models: ["Market","Organisation"]) {
... on Market {
id
name
}
... on Organisation {
id
name
}
... on AppUser {
id
email
}
}
}
Related
I have two Model classes
class Book
# attributes
# title
# status
# author_id
belongs_to :author
enum status: %w[pending published progress]
end
class Author
# attributes
has_many :books
end
I have an activerecord that return a list of books
The list
[<#Book><#Book><#Book>]
I use this function to group them
def group_by_gender_and_status
books.group_by { |book| book.author.gender }
.transform_values { |books| books.group_by(&:status).transform_values(&:count) }
end
and the outcome is
{
"female"=>{"progress"=>2, "pending"=>1, "published"=>2},
"male"=>{"published"=>3, "pending"=>4, "progress"=>4}
}
How do I merge progress and pending and name the key pending? so it would look like this
{
"female"=>{"pending"=>3, "published"=>2 },
"male"=>{"pending"=>8, "published"=>3, }
}
I prefer to use the group_by method vs the group by SQL for a reason. Thanks
def group_by_gender_and_status
books.group_by { |book| book.author.gender }.
transform_values do |books|
books.group_by { |book| book.status == 'progress' ? 'pending' : book.status }.
transform_values(&:count)
end
end
I am working on implementing a search endpoint with ruby based on a json request sent from the client which should have the form GET /workspace/:id/searches? filter[query]=Old&filter[type]=ct:Tag,User,WokringArea&items=5
The controller looks like this
class SearchesController < ApiV3Controller
load_and_authorize_resource :workspace, class: "Company"
load_and_authorize_resource :user, through: :workspace
load_and_authorize_resource :working_area, through: :workspace
def index
keyword = filtered_params[:query].delete("\000")
keyword = '%' + keyword + '%'
if filtered_params[:type].include?('User')
#users = #workspace.users.where("LOWER(username) LIKE LOWER(?)", keyword)
end
if filtered_params[:type].include?('WorkingArea')
#working_areas = #workspace.working_areas.where("LOWER(name) LIKE LOWER(?)", keyword)
end
#resources = #working_areas
respond_json(#resources)
end
private
def filtered_params
params.require(:filter).permit(:query, :type)
end
def ability_klasses
[WorkspaceAbility, UserWorkspaceAbility, WorkingAreaAbility]
end
end
respond_json returns the resources with a json format and it looks like this
def respond_json(records, status = :ok)
if records.try(:errors).present?
render json: {
errors: records.errors.map do |pointer, error|
{
status: :unprocessable_entity,
source: { pointer: pointer },
title: error
}
end
}, status: :unprocessable_entity
return
elsif records.respond_to?(:to_ary)
#pagy, records = pagy(records)
end
options = {
include: params[:include],
permissions: permissions,
current_ability: current_ability,
meta: meta_infos
}
render json: ApplicationRecord.serialize_fast_apijson(records, options), status: status
end
Now the issue is the response is supposed to look like this:
{
data: [
{
id: 32112,
type: 'WorkingArea'
attributes: {}
},
{
id: 33321,
type: 'User',
attributes: {}
},
{
id: 33221,
type: 'Tag'
attributes: {}
}
How can I make my code support responding with resources that have different types?
You can define a model, not in your database, that is based on the results from the API. Then you include some of the ActiveModel modules for more features.
# app/models/workspace_result.rb
class WorkspaceResult
include ActiveModel::Model
include ActiveModel::Validations
include ActiveModel::Serialization
attr_accessor(
:id,
:type,
:attributes
)
def initialize(attributes={})
filtered_attributes = attributes.select { |k,v| self.class.attribute_method?(k.to_sym) }
super(filtered_attributes)
end
def self.from_json(json)
attrs = JSON.parse(json).deep_transform_keys { |k| k.to_s.underscore }
self.new(attrs)
end
end
Then in your API results you can do something like:
results = []
response.body["data"].each do |result|
results << WorkspaceArea.from_json(result)
end
You can also define instance methods on this model, etc.
Closed. This question is opinion-based. It is not currently accepting answers.
Want to improve this question? Update the question so it can be answered with facts and citations by editing this post.
Closed 2 years ago.
Improve this question
Purpose of the post
I'm writing some code here to get an advise from people and see how they're writing clean ruby / rails code.
We're going to assume we have two models, User and Project. I wish to know how you'd create filters / scopes / methods for the best possible clean code.
class User < ApplicationRecord
# first_name, last_name, email
has_many :projects
end
class Project < ApplicationRecord
# title, description, published
belongs_to :user
end
Method 1
class User < ApplicationRecord
# first_name, last_name, email
has_many :projects
scope :with_first_name, -> (value) { where(first_name: value) }
scope :with_last_name, -> (value) { where(last_name: value) }
scope :with_email, -> (value) { where(email: value) }
scope :with_project_name, -> (value) { joins(:projects).where(projects: { name: value }) }
end
class UserController < ApplicationController
def index
#users = User.all
if params[:first_name].present?
#users = #users.with_first_name(params[:first_name])
end
if params[:last_name].present?
#users = #users.with_last_name(params[:last_name])
end
if params[:email].present?
#users = #users.with_email(params[:email])
end
if params[:project_name].present?
#users = #users.with_project_name(params[:project_name])
end
end
end
This can be useful, but we're gonna have a very fat controller. When we add more filters, we're going to have more and more conditions to fill.
It can also be refactored to:
class UserController < ApplicationController
def index
#users = User.all
{
first_name: :with_first_name,
last_name: :with_last_name,
email: :with_email,
project_name: :with_project_name,
}.each do |param, scope|
value = params[param]
if value.present?
#users = #users.public_send(scope, value)
end
end
end
end
but it will eliminate the possibility of having multiple params for a scope.
Method 2
Same as above, but in the model instead of controller:
class User < ApplicationRecord
# first_name, last_name, email
has_many :projects
scope :with_first_name, -> (value) { value ? where(first_name: value) : all }
scope :with_last_name, -> (value) { value.present ? where(last_name: value) : all }
scope :with_email, -> (value) { value.present ? where(email: value) : all }
scope :with_project_name, -> (value) { value.present? joins(:projects).where(projects: { name: value }) : all }
def self.filter(filters)
users = User.all
{
first_name: :with_first_name,
last_name: :with_last_name,
email: :with_email,
project_name: :with_project_name,
}.each do |param, scope|
value = filters[param]
if value.present?
users = users.public_send(scope, value)
end
end
users
end
end
class UserController < ApplicationController
def index
#users = User.filter(
params.permit(:first_name, :last_name, :email, :project_name)
)
end
end
Method 2
class User < ApplicationRecord
# first_name, last_name, email
has_many :projects
scope :with_first_name, -> (value) { value ? where(first_name: value) : all }
scope :with_last_name, -> (value) { value.present ? where(last_name: value) : all }
scope :with_email, -> (value) { value.present ? where(email: value) : all }
scope :with_project_name, -> (value) { value.present? joins(:projects).where(projects: { name: value }) : all }
end
class UserController < ApplicationController
def index
#users = User.all
#users = #users.with_first_name(params[:first_name])
#users = #users.with_last_name(params[:last_name])
#users = #users.with_email(params[:email])
#users = #users.with_project_name(params[:project_name])
end
end
This way, we add the value validation on the scope level, and we remove the param checking in the controller.
However, the repetition here is tremendous and would always return values even if the scope doesn't apply. ( ex: empty string ).
Final note
This post might not seem SO related, but would appreciate the input that anyone is going to give.
None of the above.
I would say that the cleanest way is to neither burdon your controller or User model further. Instead create a separate object which can be tested in isolation.
# Virtual model which represents a search query.
class UserQuery
include ActiveModel::Model
include ActiveModel::Attributes
attribute :first_name
attribute :last_name
attribute :email
attribute :project_name
# Loops through the attributes of the object and contructs a query
# will call 'filter_by_attribute_name' if present.
# #param [ActiveRecord::Relation] base_scope - is not mutated
# #return [ActiveRecord::Relation]
def resolve(base_scope = User.all)
valid_attributes.inject(base_scope) do |scope, key|
if self.respond_to?("filter_by_#{key}")
scope.merge(self.send("filter_by_#{key}"))
else
scope.where(key => self.send(key))
end
end
end
private
def filter_by_project_name
User.joins(:projects)
.where(projects: { name: project_name })
end
# Using compact_blank is admittedly a pretty naive solution for testing
# if attributes should be used in the query - but you get the idea.
def valid_attributes
attributes.compact_blank.keys
end
end
This is especially relevant when you're talking about a User class which usually is the grand-daddy of all god classes in a Rails application.
The key to the elegance here is using Enumerable#inject which lets you iterate accross the attributes and add more and more filters successively and ActiveRecord::SpawnMethods#merge which lets you mosh scopes together. You can think of this kind of like calling .where(first_name: first_name).where(last_name: last_name)... except in a loop.
Usage:
#users = UserQuery.new(
params.permit(:first_name, :last_name, :email, :project_name)
).resolve
Having a model means that you can use it for form bindings:
<%= form_with(model: #user_query, url: '/users/search') do |f| %>
# ...
<% end %>
And add validations and other features without making a mess.
scope :filter_users, -> (params) { where(conditions).with_project_name }
scope :with_project_name, -> (value) { value.present? joins(:projects).where(projects: { name: value }) : all }
def process_condition(attr, hash)
value = params[attr]
return hash if value.blank?
hash[attr] = value
hash
end
#This will return the conditions hash to be supplied to where. Since the param may have some other attribute which we may not need to apply filter, we construct conditions hash here.
def conditions
hash = {}
%i[last_name first_name email].each do |attr|
hash = process_condition(attr, hash)
end
hash
end
Finally, I would recommend you to check out ransack gem and the demo for the app is ransack demo. You can just use the search result of this gem by which you can support more filter options.
I'm trying to create a separate percolator index in Elasticsearch using Searchkick. I'd like SavedSearch to be able to percolate the Product index and thus (I believe) need the SavedSearch and Product mappings to be the same with the addition of the percolator property in the SavedSearch mapping.
The solution below seems to be working, but it also seems clumsy. Does anyone have any suggestions for a better way to accomplish this?
class Product < ApplicationRecord
searchkick callbacks: false, batch_size: 200
def self.mappings_for_percolator
_mapping_name, full_mapping = Product.search_index.mapping.max_by { |k, _v|
Time.parse(k[/\d+/])
}
mapping = full_mapping["mappings"]["product"]
mapping["properties"]["query"] = { "type" => "percolator" }
mapping
end
end
class SavedSearch < ApplicationRecord
searchkick merge_mappings: true, mappings: {
saved_search: Product.mappings_for_percolator
}
validates :query, presence: true
validate :query_is_valid
def self.percolate_product(id)
q = {
query: {
constant_score: {
filter: {
percolate: {
field: "query",
index: Product.search_index.name,
type: "product",
id: id
}
}
}
}
}
search(body: q.to_json)
end
def query_is_valid
result = Searchkick.client.perform_request(
"GET",
"#{Product.search_index.name}/_validate/query",
{},
{ query: query }.to_json
).body
return if result["valid"]
errors.add(:query, "is invalid")
end
end
I want to use ElasticSearch to search with multiple parameters (name, sex, age at a time).
what I've done so far is included elastic search in my model and added a as_indexed_json method for indexing and included relationship.
require 'elasticsearch/model'
class User < ActiveRecord::Base
include Elasticsearch::Model
include Elasticsearch::Model::Callbacks
belongs_to :product
belongs_to :item
validates :product_id, :item_id, :weight, presence: true
validates :product_id, uniqueness: {scope: [:item_id] }
def as_indexed_json(options = {})
self.as_json({
only: [:id],
include: {
product: { only: [:name, :price] },
item: { only: :name },
}
})
end
def self.search(query)
# i'm sure this method is wrong I just don't know how to call them from their respective id's
__elasticsearch__.search(
query: {
filtered: {
filter: {
bool: {
must: [
{
match: {
"product.name" => query
}
}
],
must: [
{
match: {
"item.name" => query
}
}
]
}
}
}
}
)
end
end
User.import force: true
And In controller
def index
#category = Category.find(params[:category_id])
if params[:search].present? and params[:product_name].present?
#users = User.search(params[:product_name]).records
end
if params[:search].present? and params[:product_price].present?
#users = User.search(params[:product_price]).records
end
if params[:search].present? and params[:item].present?
if #users.present?
#users.search(item: params[:item], product: params[:product_name]).records
else
#users = User.search(params[:item]).records
end
end
end
There are basically 3 inputs for searching with product name , product price and item name, This is what i'm trying to do like if in search field only product name is present then
#users = User.search(params[:product_name]).records
this will give me records but If user inputs another filter say product price or item name in another search bar then it's not working. any ideas or where I'm doing wrong :/ stucked from last 3 days