ash_weight

A metric weight Ash.Type for the Ash Framework.

Stored canonically as integer milligrams (BIGINT in Postgres), with ergonomic conversions to grams and kilograms via Decimal to avoid float drift.

attribute :weight, AshWeight, constraints: [min: 0]

Installation

def deps do
  [
    {:ash_weight, "~> 0.1.0"}
  ]
end

Usage

Constructing weights

AshWeight.new(250)             # %AshWeight{mg: 250}        (default unit is :mg)
AshWeight.new(1.5, :g)         # %AshWeight{mg: 1500}
AshWeight.new(2, :kg)          # %AshWeight{mg: 2_000_000}

AshWeight.from_mg(250)
AshWeight.from_g("1.5")        # strings and Decimals also accepted
AshWeight.from_kg(Decimal.new("2.25"))

from_g/1 and from_kg/1 normalize through Decimal arithmetic, so AshWeight.from_g(0.1).mg == 100 exactly — no 1.1 * 1000 == 1100.0000…2 surprises.

Converting

weight = AshWeight.new(1.5, :g)

AshWeight.to_mg(weight)        # 1500                    (integer)
AshWeight.to_g(weight)         # #Decimal<1.5>
AshWeight.to_kg(weight)        # #Decimal<0.0015>

Displaying

String.Chars picks the largest unit that gives |value| >= 1:

to_string(AshWeight.from_mg(250))   # "250 mg"
to_string(AshWeight.from_g(1.5))    # "1.5 g"
to_string(AshWeight.from_kg(2.25))  # "2.25 kg"

Using it as an Ash attribute

defmodule MyApp.Harvest do
  use Ash.Resource,
    domain: MyApp.Cultivation,
    data_layer: AshPostgres.DataLayer

  attributes do
    uuid_primary_key :id
    attribute :yield, AshWeight, public?: true, constraints: [min: 0]
  end
end

MyApp.Harvest
|> Ash.Changeset.for_create(:create, %{yield: {1.5, :kg}})
|> Ash.create()

The Ash.Type accepts:

Input Example
%AshWeight{}%AshWeight{mg: 1500}
Integer (mg) 1500
{value, unit} tuple {1.5, :g}, {2, :kg}
Atom-keyed map %{value: 1.5, unit: :g}
String-keyed map %{"value" => "1.5", "unit" => "g"}
nilnil

Constraints

attribute :weight, AshWeight, constraints: [min: 0, max: 10_000_000]
Option Description
:min Minimum weight in milligrams (inclusive).
:max Maximum weight in milligrams (inclusive).

Arithmetic and comparison

AshWeight.add(a, b)
AshWeight.subtract(a, b)
AshWeight.multiply(weight, 3)         # integer or Decimal scalar
AshWeight.compare(a, b)               # :lt | :eq | :gt
AshWeight.equal?(a, b)

Why integer milligrams?

Same reasoning as storing money in integer cents: bigint storage is exact, sortable in SQL without NUMERIC, and skips Decimal allocation on every read. 1 mg is sufficient resolution for cultivation, dosing, and inventory weights.

User input still goes through Decimal, so 1.5 g round-trips exactly to 1500 mg and back.

Limitations

Metric only. This package handles :mg, :g, and :kg — and that's deliberate. No pounds, ounces, stones, drams, slugs, grains, hogsheads, potatoes per square cornfield, or any of the other charming units invented to make trade harder. If you need to ingest imperial values from a vendor feed, convert at the edge:

mg = round(ounces * 28_349.523125)
AshWeight.from_mg(mg)

The stored representation is unitless integer mg, so converting at the boundary is the right place to do it anyway.

No sub-mg precision. Integer milligrams means 0.5 mg rounds. If you need microgram (µg) precision — e.g. cannabinoid trace measurements — this isn't the right type. Adding :numeric storage and a Decimal-backed mg field is a contained change; open an issue.

The original input unit is not stored.AshWeight.new(1.5, :g) and AshWeight.new(1500, :mg) are the same value after construction. If you need to remember that a user typed "1.5 g" rather than "1500 mg" (e.g. for a printable lab certificate), track the display unit in a sibling attribute. Unlike money — where the currency is essential, not cosmetic — the unit on a metric weight is a presentation choice.

License

MIT