Orkestra.ES.PagedQuery (orkestra v0.2.0)

Copy Markdown View Source

Pure builder and response parser behind Orkestra.ES.Repository.get_paged/1.

build/2 compiles a high-level option list into a full Elasticsearch _search request body; parse_response/3 turns the search response back into an Orkestra.ES.Page. Both functions are pure — they only manipulate maps, lists and strings, have no dependency on Snap, and are therefore testable without any HTTP layer. build/2 is layered on top of Orkestra.ES.Query for the bool-query and sort assembly.

Options

  • :search — a query string. Compiled into a multi_match over the schema's searchable_fields. The multi_match uses the default best_fields type (natural relevance boosting across fields) and runs in must context so it contributes to the score. Requesting a search on a schema with no searchable fields returns {:error, :no_searchable_fields}.

    Searchable fields inside embeds participate too. Fields reached through mode: :object embeds enter the multi_match with their dotted path ("items.name"). Fields inside mode: :nested embeds cannot be matched by a root-level multi_match (an Elasticsearch limitation), so when any nested embed carries searchable fields the must clause becomes an inner bool should (minimum_should_match: 1) containing the root+object multi_match plus one nested query (correct path) with a multi_match on its fields for each such embed — recursively for nested-inside-nested (composed paths). {:error, :no_searchable_fields} is returned only when no searchable field exists anywhere in the tree.

  • :filters — a keyword list or map of field => spec. The clause is derived from the field's declared type:

    • :keyword / :boolean / {:array, :keyword} — a scalar becomes a term, a list becomes a terms, both in filter context.

    • numeric (:integer/:long/:float/:double) and :date — a scalar becomes a term; a {:gt | :gte | :lt | :lte, value} tuple becomes a one-sided range; a {:range, from, to} becomes a gte/lte range (a nil bound is omitted); a list of op tuples is merged into a single combined range. All in filter context.

    • :text — a match, in must context (contributes to the score).

    • the facets slot (see below) — a list/keyword of {attr_code, value_code} pairs. Each pair produces one nested query in filter context matching attr_code and value_code (a list of value codes becomes a terms). Multiple pairs are AND-combined.

    • an embed name — the spec is a keyword list (or map) of sub-filters over the embedded schema's fields, each derived from its declared type exactly as above:

      filters: [items: [sku: "X", quantity: {:gte, 2}]]

      For a mode: :object embed every sub-filter becomes an independent clause on the dotted path ("items.sku", "items.quantity") in its usual context. Beware the cross-entry false positives: with an embeds_many in object mode the sub-filters are not correlated to the same entry — a document matches if any entry satisfies each condition separately. For a mode: :nested embed the sub-filters are combined into one nested query with an inner bool, so all conditions must hold on the same entry. Sub-filters may recurse into deeper embeds by name. An unknown field inside an embed returns {:error, {:unknown_filter_field, "items.sku_typo"}} with the full dotted path.

    An unknown top-level field returns {:error, {:unknown_filter_field, field}}.

  • :facetsfalse (default), true, or a list of attribute codes. Requires the schema to declare a facets slot, otherwise {:error, :no_facets_field}. The aggregation is a nested agg on the facets path, a terms on attr_code (size 100, optionally restricted with include when a code list is given), each attribute carrying a size-1 terms on attr_name for its display name and a terms sub-aggregation on value_code (size 100) with a size-1 terms on value_name. Because all active filters live in the query, the aggregation counts reflect them automatically. Facets are therefore conjunctive (a filtered attribute constrains the counts of the others); disjunctive facets are out of scope. The size limits (100 attributes, 100 values per attribute) are fixed.

  • :sort — a keyword list of field => :asc | :desc. The field must exist; a :text field is only sortable through its keyword sub-field, so it requires keyword: true or sortable: true, otherwise {:error, {:not_sortable, field}}. Sorting on embedded fields (either the embed name or a dotted path) is not supported — a current limitation — and returns {:error, {:not_sortable, field}}. The schema's primary_key is always appended as a final asc tiebreaker (unless already present), which keeps search_after cursors stable even when no sort is supplied.

  • :page / :page_size — offset pagination (defaults 1 / 20), compiled into from/size.

  • :after — a cursor string for search_after pagination, mutually exclusive with :page ({:error, :conflicting_pagination}). The cursor is the URL-safe Base64 of the JSON-encoded sort values of a previous page's last hit; a malformed cursor returns {:error, :invalid_cursor}.

The request body always sets "track_total_hits" => true so total is exact for total_pages.

Summary

Functions

Builds a full Elasticsearch _search request body from opts.

Turns a search response into an Orkestra.ES.Page.

Functions

build(schema, opts)

@spec build(
  module(),
  keyword()
) :: {:ok, map()} | {:error, term()}

Builds a full Elasticsearch _search request body from opts.

Returns {:ok, body} or {:error, reason} (see the module doc for the possible reasons). body is a string-keyed map ready to hand to Snap.Search.search/3.

parse_response(schema, opts, response)

@spec parse_response(module(), keyword(), map() | struct()) :: Orkestra.ES.Page.t()

Turns a search response into an Orkestra.ES.Page.

response may be a raw response body map (string-keyed) or a Snap.SearchResponse struct — the parser reads both without referencing Snap at compile time. opts are the same options passed to build/2 (they carry the pagination mode and whether facets were requested).