defmodule AshWeight do
@moduledoc """
A metric weight type for [Ash](https://hexdocs.pm/ash).
Stored canonically as integer milligrams (`BIGINT` in Postgres), with
ergonomic conversions to grams and kilograms via `Decimal` to avoid float
drift.
## Usage
attribute :weight, AshWeight, constraints: [min: 0]
AshWeight.new(1.5, :g) # => %AshWeight{mg: 1500}
AshWeight.from_kg("2.25") # => %AshWeight{mg: 2_250_000}
AshWeight.to_kg(weight) # => #Decimal<0.0015>
to_string(AshWeight.from_kg(1.5)) # => "1.5 kg"
Accepted input shapes for `Ash.Type.cast_input/3`:
* `%AshWeight{}`
* integer — treated as milligrams
* `{value, :mg | :g | :kg}` — explicit unit
* `%{value: _, unit: _}` / `%{"value" => _, "unit" => _}` — form data
* `nil`
"""
use Ash.Type
@enforce_keys [:mg]
defstruct [:mg]
@type unit :: :mg | :g | :kg
@type t :: %__MODULE__{mg: integer()}
@units [:mg, :g, :kg]
@g_per_mg 1_000
@kg_per_mg 1_000_000
# ---------------------------------------------------------------------------
# Constructors
# ---------------------------------------------------------------------------
@doc """
Builds a weight from `value` in the given metric `unit` (default `:mg`).
`value` may be an integer, float, `Decimal`, or numeric string. Internally
normalized through `Decimal` arithmetic and rounded to integer milligrams,
so `new(0.1, :g)` is exactly `100` mg with no float drift.
"""
@spec new(value :: integer() | float() | Decimal.t() | String.t(), unit) :: t()
def new(value, unit \\ :mg)
def new(value, :mg), do: from_mg(value)
def new(value, :g), do: from_g(value)
def new(value, :kg), do: from_kg(value)
@doc "Builds a weight from a milligram value."
@spec from_mg(integer() | float() | Decimal.t() | String.t()) :: t()
def from_mg(value) when is_integer(value), do: %__MODULE__{mg: value}
def from_mg(value), do: %__MODULE__{mg: to_int_mg(value, 1)}
@doc "Builds a weight from a gram value (mass × 1_000 mg)."
@spec from_g(integer() | float() | Decimal.t() | String.t()) :: t()
def from_g(value), do: %__MODULE__{mg: to_int_mg(value, @g_per_mg)}
@doc "Builds a weight from a kilogram value (mass × 1_000_000 mg)."
@spec from_kg(integer() | float() | Decimal.t() | String.t()) :: t()
def from_kg(value), do: %__MODULE__{mg: to_int_mg(value, @kg_per_mg)}
# ---------------------------------------------------------------------------
# Conversions
# ---------------------------------------------------------------------------
@doc "Returns the weight in integer milligrams."
@spec to_mg(t()) :: integer()
def to_mg(%__MODULE__{mg: mg}), do: mg
@doc "Returns the weight in grams as a `Decimal`."
@spec to_g(t()) :: Decimal.t()
def to_g(%__MODULE__{mg: mg}), do: Decimal.div(Decimal.new(mg), @g_per_mg)
@doc "Returns the weight in kilograms as a `Decimal`."
@spec to_kg(t()) :: Decimal.t()
def to_kg(%__MODULE__{mg: mg}), do: Decimal.div(Decimal.new(mg), @kg_per_mg)
# ---------------------------------------------------------------------------
# Arithmetic / comparison
# ---------------------------------------------------------------------------
@doc "Adds two weights."
@spec add(t(), t()) :: t()
def add(%__MODULE__{mg: a}, %__MODULE__{mg: b}), do: %__MODULE__{mg: a + b}
@doc "Subtracts `b` from `a`."
@spec subtract(t(), t()) :: t()
def subtract(%__MODULE__{mg: a}, %__MODULE__{mg: b}), do: %__MODULE__{mg: a - b}
@doc "Multiplies a weight by a scalar."
@spec multiply(t(), integer() | float() | Decimal.t() | String.t()) :: t()
def multiply(%__MODULE__{mg: mg}, scalar) when is_integer(scalar),
do: %__MODULE__{mg: mg * scalar}
def multiply(%__MODULE__{mg: mg}, scalar) do
%__MODULE__{
mg:
Decimal.new(mg)
|> Decimal.mult(to_decimal(scalar))
|> Decimal.round(0)
|> Decimal.to_integer()
}
end
@doc "Compares two weights. Returns `:lt`, `:eq`, or `:gt`."
@spec compare(t(), t()) :: :lt | :eq | :gt
def compare(%__MODULE__{mg: a}, %__MODULE__{mg: b}) when a < b, do: :lt
def compare(%__MODULE__{mg: a}, %__MODULE__{mg: b}) when a > b, do: :gt
def compare(%__MODULE__{}, %__MODULE__{}), do: :eq
# ---------------------------------------------------------------------------
# Ash.Type behaviour
# ---------------------------------------------------------------------------
@impl Ash.Type
def storage_type(_constraints), do: :integer
@impl Ash.Type
def constraints do
[
min: [
type: :integer,
doc: "Minimum weight in milligrams (inclusive)."
],
max: [
type: :integer,
doc: "Maximum weight in milligrams (inclusive)."
]
]
end
@impl Ash.Type
def cast_input(nil, _), do: {:ok, nil}
def cast_input(%__MODULE__{} = weight, _), do: {:ok, weight}
def cast_input(mg, _) when is_integer(mg), do: {:ok, %__MODULE__{mg: mg}}
def cast_input({value, unit}, _) when unit in @units do
{:ok, new(value, unit)}
rescue
_ -> {:error, "could not cast weight #{inspect(value)} #{unit}"}
end
def cast_input(map, _) when is_map(map) and not is_struct(map) do
with {:ok, value} <- fetch_field(map, :value),
{:ok, unit_raw} <- fetch_field(map, :unit),
{:ok, unit} <- normalize_unit(unit_raw) do
try do
{:ok, new(value, unit)}
rescue
_ -> {:error, "could not cast weight #{inspect(value)} #{unit}"}
end
end
end
def cast_input(other, _), do: {:error, "invalid weight: #{inspect(other)}"}
@impl Ash.Type
def cast_stored(nil, _), do: {:ok, nil}
def cast_stored(mg, _) when is_integer(mg), do: {:ok, %__MODULE__{mg: mg}}
def cast_stored(other, _), do: {:error, "invalid stored weight: #{inspect(other)}"}
@impl Ash.Type
def dump_to_native(nil, _), do: {:ok, nil}
def dump_to_native(%__MODULE__{mg: mg}, _), do: {:ok, mg}
def dump_to_native(_, _), do: :error
@impl Ash.Type
def matches_type?(%__MODULE__{}, _), do: true
def matches_type?(_, _), do: false
@impl Ash.Type
def apply_constraints(nil, _), do: {:ok, nil}
def apply_constraints(%__MODULE__{mg: mg} = weight, constraints) do
with :ok <- check_min(mg, Keyword.get(constraints, :min)),
:ok <- check_max(mg, Keyword.get(constraints, :max)) do
{:ok, weight}
end
end
@impl Ash.Type
def equal?(%__MODULE__{mg: a}, %__MODULE__{mg: b}), do: a == b
def equal?(_, _), do: false
# ---------------------------------------------------------------------------
# Internals
# ---------------------------------------------------------------------------
defp to_int_mg(value, factor) do
value
|> to_decimal()
|> Decimal.mult(factor)
|> Decimal.round(0)
|> Decimal.to_integer()
end
defp to_decimal(v) when is_integer(v), do: Decimal.new(v)
defp to_decimal(v) when is_float(v), do: Decimal.from_float(v)
defp to_decimal(v) when is_binary(v), do: Decimal.new(v)
defp to_decimal(%Decimal{} = v), do: v
defp fetch_field(map, key) do
with :error <- Map.fetch(map, key),
:error <- Map.fetch(map, Atom.to_string(key)) do
{:error, "missing field: #{key}"}
end
end
defp normalize_unit(unit) when unit in @units, do: {:ok, unit}
defp normalize_unit("mg"), do: {:ok, :mg}
defp normalize_unit("g"), do: {:ok, :g}
defp normalize_unit("kg"), do: {:ok, :kg}
defp normalize_unit(other), do: {:error, "invalid unit: #{inspect(other)}"}
defp check_min(_mg, nil), do: :ok
defp check_min(mg, min) when mg >= min, do: :ok
defp check_min(mg, min), do: {:error, "must be at least #{min} mg, got #{mg}"}
defp check_max(_mg, nil), do: :ok
defp check_max(mg, max) when mg <= max, do: :ok
defp check_max(mg, max), do: {:error, "must be at most #{max} mg, got #{mg}"}
end
defimpl String.Chars, for: AshWeight do
@g 1_000
@kg 1_000_000
def to_string(%AshWeight{mg: 0}), do: "0 mg"
def to_string(%AshWeight{mg: mg}) when abs(mg) >= @kg do
"#{Decimal.div(Decimal.new(mg), @kg)} kg"
end
def to_string(%AshWeight{mg: mg}) when abs(mg) >= @g do
"#{Decimal.div(Decimal.new(mg), @g)} g"
end
def to_string(%AshWeight{mg: mg}), do: "#{mg} mg"
end
defimpl Inspect, for: AshWeight do
def inspect(weight, _opts), do: "#AshWeight<#{to_string(weight)}>"
end