defmodule Polymarket.Schemas.Coerce do @moduledoc """ Helpers for building schema structs directly from a decoded message, without the overhead of `Ecto.Changeset` casting. Every value that arrives from an API is coerced to the schema's field type here: numbers (`to_float/1`, `to_int/1`) — which the market feed delivers as JSON *strings* (e.g. `"0.01"`, `"1782553314962"`) — timestamps (`to_datetime/1`, `to_date/1`), JSON-encoded arrays (`to_string_list/1`, `to_float_list/1`), and nested embeds (`embed/2`, `embed_list/2`). Anything that does not parse cleanly becomes `nil` rather than raising, mirroring Ecto's lenient cast semantics. The numeric coercions and `require_fields/2` sit on the websocket hot path — keep them branch-cheap and allocation-free. """ @doc """ Coerces a decoded JSON value to a `float`. Accepts a float, an integer, or a fully-numeric binary. Returns `nil` for `nil` or any value that does not parse cleanly. """ @spec to_float(term()) :: float() | nil def to_float(nil), do: nil def to_float(f) when is_float(f), do: f def to_float(i) when is_integer(i), do: i * 1.0 def to_float(b) when is_binary(b) do case Float.parse(b) do {f, ""} -> f _ -> nil end end def to_float(_), do: nil @doc """ Coerces a decoded JSON value to an `integer`. Accepts an integer or a fully-numeric binary. Returns `nil` for `nil` or any value that does not parse cleanly. """ @spec to_int(term()) :: integer() | nil def to_int(nil), do: nil def to_int(i) when is_integer(i), do: i def to_int(b) when is_binary(b) do case Integer.parse(b) do {i, ""} -> i _ -> nil end end def to_int(_), do: nil @doc """ Validates that each field in `fields` is present (non-`nil`) on `struct`. Returns `{:ok, struct}` when all are present, otherwise `{:error, {:missing_fields, missing}}`. This is the lightweight replacement for `Ecto.Changeset.validate_required/2` on the hot path. """ @spec require_fields(struct(), [atom()]) :: {:ok, struct()} | {:error, {:missing_fields, [atom()]}} def require_fields(struct, fields) do case for(field <- fields, is_nil(Map.fetch!(struct, field)), do: field) do [] -> {:ok, struct} missing -> {:error, {:missing_fields, missing}} end end @doc """ Coerces a decoded JSON value to a UTC `DateTime`. Accepts a `DateTime` or an ISO 8601 string (with or without an offset; a naive string is read as UTC). Returns `nil` for `nil` or anything that does not parse. """ @spec to_datetime(term()) :: DateTime.t() | nil def to_datetime(nil), do: nil def to_datetime(%DateTime{} = datetime), do: datetime def to_datetime(value) when is_binary(value) do case DateTime.from_iso8601(value) do {:ok, datetime, _offset} -> datetime {:error, :missing_offset} -> case NaiveDateTime.from_iso8601(value) do {:ok, naive} -> DateTime.from_naive!(naive, "Etc/UTC") _ -> nil end _ -> nil end end def to_datetime(_), do: nil @doc """ Coerces a decoded JSON value to a `Date`. Accepts a `Date` or an ISO 8601 date string. Returns `nil` for `nil` or anything that does not parse. """ @spec to_date(term()) :: Date.t() | nil def to_date(nil), do: nil def to_date(%Date{} = date), do: date def to_date(value) when is_binary(value) do case Date.from_iso8601(value) do {:ok, date} -> date _ -> nil end end def to_date(_), do: nil @doc """ Coerces a field that holds a list of strings. Some Gamma fields arrive as a JSON-encoded *string* (e.g. `"[\\"Up\\", \\"Down\\"]"`); those are decoded. An already-decoded list is passed through, an empty string becomes `[]`, and `nil` passes through. """ @spec to_string_list(term()) :: [String.t()] | nil def to_string_list(value), do: decode_list(value, & &1) @doc """ Coerces a field that holds a list of floats, decoding a JSON-encoded string as `to_string_list/1` does and coercing each element with `to_float/1`. """ @spec to_float_list(term()) :: [float()] | nil def to_float_list(value), do: decode_list(value, fn list -> Enum.map(list, &to_float/1) end) @spec decode_list(term(), ([term()] -> list())) :: list() | nil defp decode_list(nil, _fun), do: nil defp decode_list("", _fun), do: [] defp decode_list(list, fun) when is_list(list), do: fun.(list) defp decode_list(value, fun) when is_binary(value) do case Jason.decode(value) do {:ok, list} when is_list(list) -> fun.(list) _ -> nil end end defp decode_list(_other, _fun), do: nil @doc """ Builds one embedded struct from `attrs` via `module.from_attrs/1`, or `nil` when `attrs` is absent or fails to build. """ @spec embed(module(), map() | nil) :: struct() | nil def embed(_module, nil), do: nil def embed(module, attrs) do case module.from_attrs(attrs) do {:ok, struct} -> struct {:error, _reason} -> nil end end @doc """ Builds a list of embedded structs via `module.from_attrs/1`, skipping any that fail to build. `nil` becomes `[]`. """ @spec embed_list(module(), [map()] | nil) :: [struct()] def embed_list(_module, nil), do: [] def embed_list(module, list) when is_list(list) do Enum.flat_map(list, fn attrs -> case embed(module, attrs) do nil -> [] struct -> [struct] end end) end end