Upgrading to v0.3
View Sourcev0.3.0 follows dowser_client 0.4, and brings these changes with it:
- Repos replace contexts. A cluster is a module of your own —
use Dowser.OpenSearch.Repo— configured under your application and started in your supervision tree, like anEcto.Repo. The:contextoption is now:repo. - The casting moved to
dowser_client, shared withdowser_elasticsearch.Dowser.OpenSearch.CodecandDowser.OpenSearch.MappingCacherstay; the field codecs becamedowser_client's field types, and date casting is now limited to nine formats. - New casting options.
:codecis now:types, and:struct,:unknown_fieldsand a module for:mappingjoin it. - Your application starts the mapping cacher; this package no longer does.
Start with the table, then read
Watch out for silence: the date changes return
different values without raising anything. dowser_client's own
UPGRADE_GUIDE_0_4
has the transport-level details, for code that calls Dowser.Client directly.
At a glance
| v0.2 | v0.3 |
|---|---|
config :dowser_client, contexts: [default: [...]] | use Dowser.OpenSearch.Repo, otp_app: :my_app + config :my_app, MyApp.Search, ... |
the :default context | config :dowser_opensearch, default_repo: MyApp.Search |
context: :logs / context: [endpoint: ...] | repo: MyApp.Logs / repo: [endpoint: ...] |
Dowser.OpenSearch.Codec.Date (and Binary, DateRange, GeoPoint, IP, Range) | Dowser.Type.Date (and so on) |
@behaviour Dowser.OpenSearch.Codec | @behaviour Dowser.Type |
Dowser.OpenSearch.Codec.load/2, dump/2 | Dowser.Type.load/2, dump/2 |
Dowser.OpenSearch.MappingError | Dowser.MappingError |
codec: MyApp.Type (per request, on the codec, or config :dowser_opensearch, codec:) | types: MyApp.Type |
| the mapping cacher, started by this package | started by your application: children = [..., Dowser.OpenSearch.MappingCacher] |
config :dowser_opensearch, mapping_cacher_opts: [...] | the child's options: {Dowser.OpenSearch.MappingCacher, ttl: ...} |
MappingCacher.put(index, mapping, context: ...) | MappingCacher.put(index, mapping, repo: ...) — required |
flattened and flat_object opaque | flat_object opaque — flattened is Elasticsearch's |
What stays: config :dowser_opensearch, mappings: / mapping_failure:, the [:dowser_opensearch, :mapping, :failure] telemetry
event, Dowser.OpenSearch.Repository, and every API function's signature.
1. Bump the dependency
{:dowser_opensearch, "~> 0.3.0"}mix deps.update dowser_opensearch dowser_client
2. Define a repo
Each context becomes a module:
defmodule MyApp.Search do
use Dowser.OpenSearch.Repo, otp_app: :my_app
endIts configuration moves to your application, under the module's name — the
keys are the same — and the repo the default context stood for becomes
:default_repo:
- config :dowser_client,
- contexts: [
- default: [
- endpoint: "http://localhost:9200",
- decoder: Dowser.OpenSearch.Codec,
- encoder: Dowser.OpenSearch.Codec
- ]
- ]
+ config :my_app, MyApp.Search,
+ endpoint: "http://localhost:9200",
+ decoder: Dowser.OpenSearch.Codec,
+ encoder: Dowser.OpenSearch.Codec
+
+ config :dowser_opensearch, default_repo: MyApp.SearchStart it with the rest of your application:
children = [MyApp.Repo, MyApp.Search, MyAppWeb.Endpoint]A repo that isn't started still works — it resolves its configuration on every request instead of once — so scripts and Mix tasks need nothing special.
Three things a repo adds:
init/2, for configuration that can't live in a config file — seeDowser.Repo;- dynamic instances: the same module started again under other names — a
cluster per tenant — chosen per process (
put_dynamic_repo/1) or per call (repo: {MyApp.Search, :tenant_a}); verify: true, which makes the repo check at startup that its endpoint really is OpenSearch (GET /reports"distribution": "opensearch"), and fail to start otherwise.MyApp.Search.engine()then returns the version it found. It is off by default: a cluster that is down would stop your application from booting.
Start the mapping cacher yourself
This package used to start Dowser.OpenSearch.MappingCacher on its own. It no
longer does: if you cast against the cluster's mappings — a repo with
decoder:/encoder: set to Dowser.OpenSearch.Codec — start it with your repo,
and move its options there:
- config :dowser_opensearch, mapping_cacher_opts: [ttl: :timer.minutes(10)]
children = [
MyApp.Search,
+ {Dowser.OpenSearch.MappingCacher, ttl: :timer.minutes(10)}
]A cacher that isn't started now fails the request with a Dowser.MappingError
naming it, where v0.2 would have left every value uncast. You don't need it
at all if you give the mappings yourself — config :dowser_opensearch, mapping: MyApp.Mappings for the whole application, or struct: / mapping: per call.
3. :context is :repo
- Dowser.OpenSearch.Search.search(query, index: "logs-*", context: :logs)
+ Dowser.OpenSearch.Search.search(query, index: "logs-*", repo: MyApp.Logs)A call without :repo goes to :default_repo, as it went to the :default
context. A call that still passes :context fails with an error naming
:repo; a call with neither, and no :default_repo configured, fails with
:no_repo. The test suite finds them all.
The same goes for Dowser.OpenSearch.Repository modules: their functions take
repo: where they took context:.
A repo also exposes the HTTP verbs, for an endpoint this package doesn't cover:
MyApp.Search.post("/_plugins/_ml/models/_search", %{query: %{match_all: %{}}})4. Field codecs are field types
The per-type modules moved to dowser_client, and the behaviour with them:
defmodule MyApp.Type do
- @behaviour Dowser.OpenSearch.Codec
+ @behaviour Dowser.Type
@impl true
def load(value, %{"type" => "scaled_float", "scaling_factor" => factor}), do: value / factor
- def load(value, field), do: Dowser.OpenSearch.Codec.load(value, field)
+ def load(value, field), do: Dowser.Type.load(value, field)
@impl true
def dump(value, %{"type" => "scaled_float", "scaling_factor" => factor}), do: round(value * factor)
- def dump(value, field), do: Dowser.OpenSearch.Codec.dump(value, field)
+ def dump(value, field), do: Dowser.Type.dump(value, field)
endDowser.OpenSearch.Codec is still the module a repo names as :decoder and
:encoder, but it no longer has load/2, dump/2 or a behaviour of its own.
The option pointing it at your type module is now :types — per request, on
the codec, or globally:
- Document.get("posts", "1", codec: MyApp.Type)
+ Document.get("posts", "1", types: MyApp.Type)
- decoder: {Dowser.OpenSearch.Codec, codec: MyApp.Type}
+ decoder: {Dowser.OpenSearch.Codec, types: MyApp.Type}
- config :dowser_opensearch, codec: MyApp.Type
+ config :dowser_opensearch, types: MyApp.TypeA request that still passes codec: fails with an error naming :types, and
so does a codec or an application environment still configured with it — a
:codec left in place would otherwise be ignored, and every value come back
uncast.
New casting options
:types sits with four more options, all set the same three ways — see
Dowser.OpenSearch.Codec:
:mappingtakes a module now, not only a map: aDowser.Mappingimplementation, or a document module (its own mapping);:structloads every document into a document module's struct (Dowser.Schema), and dumps it back;:unknown_fields: :raisereturns aDowser.UnknownFieldErrorfor a field the mapping doesn't declare (the default,:keep, is what v0.2 did);:mapping_failureis unchanged.
5. MappingError moved
- rescue error in Dowser.OpenSearch.MappingError -> ...
+ rescue error in Dowser.MappingError -> ...
- {:error, %Dowser.OpenSearch.MappingError{index: index}} -> ...
+ {:error, %Dowser.MappingError{index: index}} -> ...6. Tests
MappingCacher.put/3 needs the repo the entry belongs to, and raises without
one. In v0.2 an entry put without a context was keyed under the :default
context, and when there was none, nothing was stored at all:
- Dowser.OpenSearch.MappingCacher.put("posts", mapping)
+ Dowser.OpenSearch.MappingCacher.put("posts", mapping, repo: MyApp.Search)config :dowser_opensearch, mappings: %{...} is unchanged, and needs no
running cacher.
Watch out for silence
Nothing below raises: the same document simply comes back with different values. Grep your mappings, then your code.
Dates are cast for nine formats only
A date field is cast only when its mapping names one of these formats, each read by the Elixir function that parses it:
| format | Elixir |
|---|---|
epoch_millis, epoch_second | DateTime |
strict_date | Date |
strict_date_hour_minute_second, …_millis | NaiveDateTime |
strict_date_time_no_millis, strict_date_time | DateTime |
strict_hour_minute_second, …_millis | Time |
Any other format is returned as stored — and so is a date field with no
format at all, which OpenSearch reads as
strict_date_optional_time||epoch_millis. In v0.2 that default was cast, so a
field like "published_at": {"type": "date"} used to come back as a
DateTime and now comes back as a string.
For a new index, give each date field one of the nine formats. An existing index can't change a field's format in place, so until it is reindexed, a type module can tell the casting which formats to read the field with:
defmodule MyApp.Type do
@behaviour Dowser.Type
# Fields mapped without a format: read them as date-times, dates or epoch
# millis — written back as date-times.
@default %{"format" => "strict_date_time||strict_date||epoch_millis"}
@impl true
def load(value, %{"type" => "date"} = field) when not is_map_key(field, "format"),
do: Dowser.Type.load(value, Map.merge(field, @default))
def load(value, field), do: Dowser.Type.load(value, field)
@impl true
def dump(value, %{"type" => "date"} = field) when not is_map_key(field, "format"),
do: Dowser.Type.dump(value, Map.merge(field, @default))
def dump(value, field), do: Dowser.Type.dump(value, field)
endconfig :dowser_opensearch, types: MyApp.TypeDates are written exactly, and in UTC
- A date is written at exactly its format's precision: truncated to the second
or the millisecond, and padded to three digits for a
_millisformat —~U[2026-08-11 00:00:00Z]is written"2026-08-11T00:00:00.000Z"tostrict_date_time, where v0.2 wrote"2026-08-11T00:00:00Z". - A
DateTimein another time zone is written as its UTC instant (Z). A stored offset (-04:00) is still applied and dropped on the way in, and-00:00is now read as UTC rather than left as a string.
Ranges are cast only when the struct holds them exactly
A date_range becomes a Date.Range only when it is mapped strict_date; with
any other format, or none, it comes back as stored. v0.2 cast a format-less
range whose bounds were plain dates, and raised on one whose bounds had a
time. An
integer_range (and now a long_range) becomes a Range only with integer
gte and lte bounds. An open-ended range, or gt/lt bounds, come back as
stored.
The mapping decides what a value is
- A
geo_pointstored as{"lat": …, "lon": …}now loads to{lat, lon}. v0.2 never handed that shape to its type, so it stayed a map. - A map its type leaves uncast — a
geo_shape, aninteger_rangewith float bounds — has its keys go through:keyslike the rest of the document. - A field the mapping doesn't know keeps its value as is. v0.2 walked it against its parent's fields.
- A
flattenedfield is no longer opaque — it is Elasticsearch's type, not OpenSearch's. Aflat_objectfield still is. - A struct sent as a document is now cast against the mapping, as
dowser_elasticsearchalready did: under an object it is turned into a map and walked, under a leaf it goes to its type.