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"}
]
endUsage
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"} |
nil | nil |
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