Orkestra.ES.Schema (orkestra v0.2.0)

Copy Markdown View Source

Declarative, Ecto-like schema DSL for Elasticsearch/OpenSearch read models.

A schema declares its index, optional cultures, index settings with per-culture analyzers, and a set of typed fields. From that declaration the macro generates a struct, introspection, the full ES index mapping (analyzers included), a deterministic mapping hash, and document casting — all as a pure module (it produces only maps and structs, never calls Snap), so it can be tested without any storage dependency.

Defining a schema

defmodule MyApp.Search.Product do
  use Orkestra.ES.Schema,
    index: "products",
    cultures: [:it, :en],
    default_culture: :it

  settings number_of_shards: 1 do
    analyzer :product_search, for: :it,
      tokenizer: "standard", filter: ["lowercase", "asciifolding", :stemmer_it]
    analyzer :product_search, for: :en,
      tokenizer: "standard", filter: ["lowercase", "porter_stem"]
    filter :stemmer_it, for: :it, type: "stemmer", language: "light_italian"
  end

  schema do
    field :product_id,  :keyword, primary_key: true
    field :name,        :text,    analyzer: :product_search, searchable: true, keyword: true
    field :category,    :keyword
    field :price,       :float
    field :released_at, :date,    sortable: true
    field :tags,        {:array, :keyword}
    facets :attributes
  end
end

Options for use

  • :index (required) — the base index name.
  • :cultures (optional) — a list of atoms; when present the schema is multi-culture and gets one alias per culture (products_it). Omitting it yields a mono-culture schema with a single unsuffixed alias.
  • :default_culture (required with :cultures) — must belong to :cultures.

Field types

:keyword, :text, :integer, :long, :float, :double, :boolean, :date, and {:array, scalar}.

Field options

  • primary_key: true — exactly one field, must be :keyword. Its value is used as the document _id.
  • analyzer: :name:text only; references a logical analyzer defined per culture in settings.
  • searchable: true:text only; marks the field for full-text search.
  • keyword: true:text only; adds a "keyword" sub-field of type keyword.
  • sortable: true — for :text implies the keyword sub-field (which is the one to sort on); for other types it is metadata only.
  • format::date only; a custom ES date format. Fields with a custom format keep their raw string when decoded.
  • default: — the struct default for the field.

Generated API

  • t() struct with all fields (plus the facets slot, defaulting to []).
  • __es_schema__/1 — introspection (see below).
  • alias_for/0, alias_for/1 — the index alias, per culture.
  • mapping/0, mapping/1 — the full string-keyed index mapping.
  • mapping_hash/0, mapping_hash/1 — deterministic SHA-256 of the mapping.
  • to_doc/1 — struct to indexable document.
  • from_hit/1_source map to struct.

__es_schema__/1 accepts :index, :cultures ([] for mono-culture), :default_culture, :fields (a list of %{name:, type:, opts:}), :field_names, :primary_key, :searchable_fields, :facets_field, and :sortable_fields.

Facets

A schema may declare a single facets :field_name slot with the fixed structure defined by Orkestra.ES.Facet (attribute code/name owning values code/name/count). It maps to a flattened nested field (attr_code/attr_name/value_code/value_name).

Embedded schemas

A schema declared with embedded: true describes a nested struct that lives inside a root document rather than an index of its own:

defmodule MyApp.Search.OrderItem do
  use Orkestra.ES.Schema, embedded: true

  schema do
    field :sku,      :keyword
    field :name,     :text, searchable: true, analyzer: :product_search
    field :quantity, :integer
  end
end

Root-only constructs are forbidden on an embedded schema and raise an ArgumentError at compile time: the :index / :cultures / :default_culture options, the settings block, primary_key: true fields, and the facets slot. Analyzer references (analyzer: :name) are allowed — they are resolved by the root schema, which validates per-culture coverage over its entire embed tree. Recursive embedding (an embedded schema that itself declares embeds_one/embeds_many) is fully supported.

An embedded schema still generates the struct, __es_schema__/1 introspection (including :embedded?true and :analyzer_refs), to_doc/1 and from_hit/1, so it works standalone in tests.

Embedding into a root schema

Inside the schema block of a root (or of another embedded schema):

embeds_one  :shipping, MyApp.Search.Address                 # mode: :object
embeds_many :items,    MyApp.Search.OrderItem, mode: :nested

embeds_one defaults the struct field to nil, embeds_many to []. The target module must be a schema compiled with embedded: true. Embed names are exposed via __es_schema__(:embeds) (not :field_names) as maps %{name:, schema:, cardinality: :one | :many, mode: :object | :nested}.

mode: :object vs mode: :nested

  • :object (the default) maps the embed as a plain "object". ES flattens object arrays into parallel value lists, so correlation between fields of the same entry is lost: with items [%{sku: "A", qty: 1}, %{sku: "B", qty: 5}] a combined filter sku == "A" and qty >= 5 matches (false positive) because each condition is satisfied by some entry. Cheapest option; fine for embeds_one or when cross-field correlation does not matter.
  • :nested maps the embed as "nested", preserving per-entry correlation (each entry is indexed as a hidden internal document): the filter above does not match. Costs extra internal documents and heavier queries — use it when combined filters on embeds_many entries must be correlated.

Summary

Functions

Declares a per-culture analyzer. Use for: to scope it to a culture.

Declares a per-culture character filter.

Declares a list of embedded structs.

Declares a single embedded struct field.

Declares the (single) facets slot for the schema.

Declares a typed field. See the module doc for types and options.

Declares a per-culture token filter.

Declares a per-culture normalizer.

Wraps the field/facets declarations of the schema.

Declares index-level settings and, in its block, the analysis definitions (analyzer/2, filter/2, tokenizer/2, char_filter/2, normalizer/2).

Declares a per-culture tokenizer.

Functions

analyzer(name, opts \\ [])

(macro)

Declares a per-culture analyzer. Use for: to scope it to a culture.

char_filter(name, opts \\ [])

(macro)

Declares a per-culture character filter.

embeds_many(name, schema, opts \\ [])

(macro)

Declares a list of embedded structs.

schema must be a module compiled with use Orkestra.ES.Schema, embedded: true. The struct field defaults to [].

Options

  • mode::object (default) or :nested. :object flattens the array (combined filters may produce cross-entry false positives); :nested preserves per-entry correlation at extra index/query cost. See the module doc for the full trade-off.

embeds_one(name, schema, opts \\ [])

(macro)

Declares a single embedded struct field.

schema must be a module compiled with use Orkestra.ES.Schema, embedded: true. The struct field defaults to nil.

Options

  • mode::object (default) or :nested. See the module doc ("mode: :object vs mode: :nested") for the flattening / correlation trade-off.

facets(name)

(macro)

Declares the (single) facets slot for the schema.

field(name, type, opts \\ [])

(macro)

Declares a typed field. See the module doc for types and options.

filter(name, opts \\ [])

(macro)

Declares a per-culture token filter.

normalizer(name, opts \\ [])

(macro)

Declares a per-culture normalizer.

schema(list)

(macro)

Wraps the field/facets declarations of the schema.

settings(opts \\ [], do_block)

(macro)

Declares index-level settings and, in its block, the analysis definitions (analyzer/2, filter/2, tokenizer/2, char_filter/2, normalizer/2).

Root-only: an embedded schema (embedded: true) raises an ArgumentError at compile time, since index settings and analyzers belong to the root.

tokenizer(name, opts \\ [])

(macro)

Declares a per-culture tokenizer.