Easily query elasticsearch given a client-side payload
Given a model set up with searchkick:
class Person < ActiveRecord::Base
searchkick
end
Person.create!(name: 'Bart')
Person.create!(name: 'Homer')
Create a corresponding Trample model:
# app/models/people_search.rb
class PeopleSearch < Trample::Search
model Person
condition :name, single: true
condition :tags
condition :age, range: true
end
You can now query via direct assignment:
search = PeopleSearch.new
search.condition(:name).eq('Bart')
search.query!
search.results.first.name # => 'Bart'
# You can also chain queries
search
.condition(:name).eq('Bart')
.condition(:age).gt(8)
.condition(:tags).or(%w(funny stupid))
.sort('-age')
.paginate(number: 2, size: 10)
Or query by constructor:
search = PeopleSearch.new(conditions: {name: 'Bart'})
search.query!
search.results.first.name # => 'Bart'
Constructor-style is particularly helpful for controllers accepting parameters.
def update
search = PeopleSearch.new(params[:people_search])
search.query!
render json: search, include: :results
end
If you're using Ember, ember-cli-advanced-search makes generating Trample-compatible search requests easy. See API Requests for more examination of request/response.
Equals
search.condition(:name).eq('Bart')
# or
PeopleSearch.new(conditions: {name: 'Bart'})
And/Or
search.condition(:tags).or(%w(Bart Homer))
search.condition(:tags).and(%w(Bart Homer))
# or
PeopleSearch.new(conditions: {tags: {values: %w(Bart Homer), and: false}})
PeopleSearch.new(conditions: {tags: {values: %w(Bart Homer), and: true}})
NOT
search.condition(:name).not('Bart')
# or
PeopleSearch.new(conditions: {name: {values: 'Bart', not: true}})
Partial Match (any text)
# This field must be configured with searchkick's 'text_middle'
search.condition(:name).any_text('ar')
# or
PeopleSearch.new(conditions: {name: {values: 'ar', any_text: true}})
Prefix Match
# This field must be configured with searchkick's 'text_start'
search.condition(:name).starts_with('Ba')
# or
PeopleSearch.new(conditions: {name: {values: 'Ba', prefix: true}})
Keywords (multi-field)
class PeopleSearch < Trample::Search
model Person
condition :keywords, fields: [:name, :description]
end
PeopleSearch.new(conditions: {keywords: 'Bart'}) # Searches across both name and description fields
Ranges
search.condition(:age).gt(8).lt(30)
search.condition(:age).gte(8).lte(30)
# or
PeopleSearch.new(conditions: {age: {from: 8, to: 30}})
PeopleSearch.new(conditions: {age: {from_eq: 8, to_eq: 30}})
Pagination
search.paginate(number: 2, size: 10)
# or
PeopleSearch.new(metadata: {pagination: {current_page: 2, per_page: 10}})
# also
search.metadata.pagination.total
search.metadata.pagination.per_page
search.metadata.pagination.current_page
Sorting
search.sort(:age) # asc
search.sort('-age') # desc
# or
PeopleSearch.new(metadata: {sort: [{age: 'asc'}]})
PeopleSearch.new(metadata: {sort: [{age: 'desc'}]})
Profiling
search.query!
search.metadata.took # milliseconds
Aggregations
Return aggregations:
class PeopleSearch < Trample::Search
model Person
aggregation :tags
end
search.agg(:tags)
# or
PeopleSearch.new(aggregations: [{name: 'tags'}])
# then
search.query!
search.aggregations.first.buckets.inject({}) { |memo, e| memo.merge(e.key.downcase => e.count) }
# {'funny' => 2, 'smart' => 11}
Select aggregations (query with this value):
search.agg(tags: ['stupid']).query!
# or
PeopleSearch.new(aggregations: [{name: 'tags', buckets: [{key: 'stupid', selected: true}] }])
Options on class definition
Any of the constructor options can apply at the class definition as well:
class PeopleSearch < Trample::Search
condition :excluded_tags, not: true
end
PeopleSearch.new(conditions: {excluded_tags: %(foo bar)}) # where tags NOT IN 'foo' or 'bar'
When autocompleting, you often want to display something to the user ('Bart') and query on something different (id=1
). This way saved searches will yield the same results even when text changes over time.
In order to accomplish this, we provide Trample::Autocomplete::Formatter
. Here's how you'd use it with active_model_serializers:
class AutocompleteSerializer < ActiveModel::Serializer
attributes :results
def results
formatter = Trample::Autocomplete::Formatter.new(instance_options[:format])
results = formatter.format_all(object.results, user_query: user_query.presence)
results
end
private
def user_query
instance_options[:user_query]
end
end
This will return results like:
{
results: [
{id: 1, key: 1, text: 'Bart'}
]
}
id is just a unique identifier, often equal to the key
. key is what we want to actually query on underneath the hood. text is for display to the user. You can now configure your client-side to display 'Bart' but query the id
condition with value 1
. See more in the API section.
Finally, there's a convenience for performing autocompletion:
# requires searchkick's 'autocomplete' indexing option
search.condition(:name).autocomplete('ba')
The following examples show API usage following the JSON API Specification. Trample itself doesn't care what your API looks like, these are just 'best practice' examples for smooth sailing.
# PUT /people_search/:id
{
data: {
attributes: {
conditions: { name: "Bart" }
}
}
}
# Response
{
data: {
id: '1dc9c145-80cb-4d39-801d-1189afe1ec8c',
type: 'people-searches',
attributes: {
conditions: { name: "Bart" },
metadata: {
took: 8,
sort: [],
pagination: { total: 1, current_page: 1, per_page: 20 }
}
},
relationships: {
results: {
data: [
{ id: 123, type: 'people' }
]
}
},
included: [
{ id: 123, type: 'people', attributes: { name: 'Bart' } }
]
}
}
Have your condition match the format { condition_name: { values: ['a', 'b'], and: true }
:
{
data: {
attributes: {
conditions: { name: { values: ['Bart', 'Homer'], and: false } }
}
}
}
Have your condition match the format { condition_name: { values: 'string or array', not: true }
:
{
data: {
attributes: {
conditions: { name: { values: 'Bart', not: true } }
}
}
}
For prefix-matching, add prefix: true
to the condition:
{
data: {
attributes: {
conditions: { name: "Ba", prefix: true }
}
}
}
To match text anywhere within a word, add any_text: true
to the condition:
{
data: {
attributes: {
conditions: { name: "ar", any_text: true } # Matches 'Bart'
}
}
}
NB: Prefix/Partial matching will only work for fields that have been indexed to support it.
Sometimes you want to supply some text and search across multiple fields - the subject AND the body of an email, for instance. You can do this server-side or client-side:
Client-side has the advantage of flexibility. But it requires the client to know more about how things are indexed:
{
data: {
attributes: {
conditions: { keywords: {values: 'foo', fields: [:subject, :body] }
}
}
}
Server-side has the "just works" advantage. The domain experts writing code will manage the keywords
fields themselves, clients get out-of-the-box functionality:
{
data: {
attributes: {
conditions: { keywords: 'foo' } # up to the backend what fields this searches
}
}
}
Use from/to
in your condition payload:
{
data: {
attributes: {
conditions: { age: { from: 7, to: 31 } }
}
}
}
Or use from_eq/to_eq
for >=/<=
functionality:
{
data: {
attributes: {
conditions: { age: { from_eq: 7, to_eq: 31 } }
}
}
}
Edit metadata.pagination.current_page
and/or metadata.pagination.per_page
:
{
data: {
attributes: {
metadata: {
pagination: { current_page: 2, per_page: 50 }
}
}
}
}
Edit metadata.sort
array. Valid values are asc/desc
:
{
data: {
attributes: {
metadata: {
sort: [{name: 'desc'}]
}
}
}
}
Specify the fields you want to aggregate on:
{
data: {
attributes: {
aggregations: [{name: 'tags'}]
}
}
}
Aggregations will be returned in the response as 'buckets':
# Response
{
data: {
id: '1dc9c145-80cb-4d39-801d-1189afe1ec8c',
type: 'people-searches',
attributes: {
aggregations: [
{
name: 'tags',
label: 'Tags', # defined server-side
order: 0, # defined server-side
buckets: [
{ count: 17, key: 'funny', label: 'Funny', selected: false }, # From ES results
{ count: 8, key: 'sad', label: 'Not Happy', selected: false }
]
}
]
}
}
}
You can change the selected
flag of an aggregation to filter results matching that entry:
# This will now only return results where 'tags' includes 'funny'
{
data: {
attributes: {
aggregations: [{name: 'tags', buckets: [{key: 'funny', selected: true}] }]
}
}
}
NB Aggregations currently only supports count
(facet) aggregations. In the future we will support sum
s, average
s, etc.
There are two parts to autocompletes. First, the typeahead - the user types "sim" in an autocomplete, sees "Simpson" an "Simmons", and selects "Simpson". Second, execute an actual search query based on the "Simpson" selection.
For the typeahead, the request/response would look like:
GET /autocompletes/last_names?filter=sim&per_page=5
# Response
{
results: [
{ id: 1, key: 1, text: 'Simpson' },
{ id: 2, key: 2, text: 'Simmons' }
]
}
In this example, id is a unique identifier for the result (it will usually be the same as 'key', except for edge-cases where key
is not unique). key is the actual value we'll query on - in this example we query on the ID of the associated record, not the string 'Simpson'. This way searches can be saved and replayed over time, giving the same results even if this person's last name changed from "Simpson" to "Barnes-Simpson". Finally, text is for display to the user.
This means you likely want to query on the key
, after the user has selected an option:
{
data: {
attributes: {
conditions: { person_id: {values: [{key: 1}]} } # Note we're querying the person_id condition
}
}
}
Finally, there are user queries. This is when the user types into an autocomplete but actually wants to search the text "sim" instead of selecting an autocomplete result. It's up to the backend developers if this is supported or not for a given search. When user queries are enabled, you'd see an extra user_query
key in the results:
GET /autocompletes/last_names?filter=sim&per_page=5
# Response
{
results: [
{ id: 'sim', key: 'sim', text: '"sim"', user_query: true},
{ id: 1, key: 1, text: 'Simpson', user_query: false },
{ id: 2, key: 2, text: 'Simmons', user_query: false }
]
}
If the user selects this option, pass it along when querying - this makes sure we query a corresponding text field for this condition:
{
data: {
attributes: {
conditions: { person_id: {values: [{key: 'sim', user_query: true}]} } # Note we're querying the person_id condition
}
}
}
If you're using swagger and swagger-blocks with JSONAPI, there are helpers to auto-generate documentation (overall request/response, valid conditions, etc):
class DocsController < ApplicationController
# ... swagger-blocks code ...
trample_swagger_schema
trample_swagger(PeopleSearch, "/people_searches")
end