Esse provides a thin DSL around Elasticsearch/OpenSearch search APIs, plus a wrapper for responses. You can pass raw query DSL hashes or combine indices and suffixes.
Running a search
From an index class
query = UsersIndex.search( body: { query: { match: { name: 'john' } } }, size: 20, from: 0)
query.response # execute and get Esse::Search::Responsequery.response.hits # array of hit hashesquery.response.total # total match countShorthand
UsersIndex.search(q: 'john') # query stringUsersIndex.search('name:john AND age:30') # Lucene query stringAcross multiple indices
query = Esse.cluster.search(UsersIndex, EventsIndex, body: { query: { match_all: {} } })Esse::Search::Query
UsersIndex.search(...) returns an Esse::Search::Query. It’s lazy — no HTTP request is made until you call .response or iterate.
Chainable helpers:
query.limit(50) # set sizequery.offset(100) # set fromquery.limit_value # => 50query.offset_value # => 100query.definition # full query hash sent to ESquery.reset! # clear cached responseExecute:
query.response # Esse::Search::Responsequery.results # alias for response.hitsPagination
Esse ships without a built-in pagination wrapper. Use:
- esse-kaminari for Kaminari-style
.page(n).per(x). - esse-pagy for Pagy-style controller helpers.
Or use .limit(size) / .offset(from) directly.
Esse::Search::Response
A thin wrapper around the raw ES/OS response:
response = query.response
response.raw_response # raw Hash (the JSON body)response.query_definition # what was sentresponse.hits # Array of hit hashes (each has _id, _source, etc.)response.total # Integer total matchesresponse.shards # shard inforesponse.aggregations # aggregations hash (if any)response.suggestions # suggestions hash (if any)
response.size # hits.lengthresponse.empty?response.each { |hit| ... } # EnumerableScrolling
For iterating through very large result sets, use scroll_hits:
UsersIndex .search(body: { query: { match_all: {} } }) .scroll_hits(batch_size: 1_000, scroll: '1m') do |batch| batch.each { |hit| process(hit['_source']) } endThe scroll context is automatically cleared when the iteration finishes.
search_after pagination
For live-updated deep pagination (preferred over from offsets beyond 10k):
UsersIndex .search( body: { query: { match_all: {} }, sort: [{ id: 'asc' }] } ) .search_after_hits(batch_size: 1_000) do |batch| batch.each { |hit| ... } endsearch_after requires a sort in the body.
Suffix targeting
Direct a search at a specific concrete index (not the alias):
UsersIndex.search(suffix: '20240401', body: { query: { match_all: {} } })Example: a search service
class UserSearch def initialize(query: nil, limit: 20, page: 1) @query = query @limit = limit @page = page end
def call UsersIndex.search( body: body, size: @limit, from: (@page - 1) * @limit ) end
private
def body { query: @query ? { multi_match: { query: @query, fields: %w[name email] } } : { match_all: {} }, sort: [{ created_at: 'desc' }] } endend
search = UserSearch.new(query: 'john', page: 2).callsearch.response.totalsearch.response.each { |hit| puts hit.dig('_source', 'name') }Integration with Jbuilder
For complex query bodies, esse-jbuilder lets you build the body from a Jbuilder template:
UsersIndex.search do |json| json.query do json.bool do json.must do json.child! { json.match { json.set! 'name', params[:q] } } end end endendCounting
UsersIndex.count(body: { query: { match: { active: true } } })# => IntegerHit format
Each hit is the raw ES response hash:
{ '_index' => 'myapp_users_20240401', '_id' => '42', '_score' => 1.2, '_source' => { 'name' => 'John', 'email' => 'john@example.com' }}Use .dig('_source', 'name') to access source fields, or wrap the response in your own result object.