Trample

Easily query elasticsearch given a client-side payload

View the Project on GitHub richmolj/trample

Implementation

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.

Query Types

Autocompletes

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')

API Requests

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.

Basic Search

# 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' } }
    ]
  }
}

And/Or Queries

Have your condition match the format { condition_name: { values: ['a', 'b'], and: true }:

{
  data: {
    attributes: {
      conditions: { name: { values: ['Bart', 'Homer'], and: false } }
    }
  }
}

NOT Queries

Have your condition match the format { condition_name: { values: 'string or array', not: true }:

{
  data: {
    attributes: {
      conditions: { name: { values: 'Bart', not: true } }
    }
  }
}

Prefix/Partial Matching

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.

Keyword (multi-field) Queries

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
    }
  }
}

Ranges

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 } }
    }
  }
}

Pagination

Edit metadata.pagination.current_page and/or metadata.pagination.per_page:

{
  data: {
    attributes: {
      metadata: {
        pagination: { current_page: 2, per_page: 50 }
      }
    }
  }
}

Sorting

Edit metadata.sort array. Valid values are asc/desc:

{
  data: {
    attributes: {
      metadata: {
        sort: [{name: 'desc'}]
      }
    }
  }
}

Aggregations (facets)

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 sums, averages, etc.

Autocompletes

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
    }
  }
}

Swagger

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