Measurement fields
Documentation home · Custom field types
A custom field can accept domain objects for writes and comparisons while
storing an ordinary indexed decimal. This recipe represents mass in grams or
kilograms and stores kilograms in decimal_value. Reads return an immutable
Inventory::Measurement with a kilograms reader; the original display unit is
not stored. This is an application example, not a built-in field type.
Define the object and field
Place the two classes in app/models/inventory/measurement.rb and
app/models/inventory/measurement_field.rb, respectively:
require "bigdecimal"
module Inventory
class Measurement
include Comparable
attr_reader :kilograms
def initialize(amount, unit: "kg")
factor = case unit.to_s
when "kg" then BigDecimal("1")
when "g" then BigDecimal("0.001")
else raise ArgumentError, "unit must be kg or g"
end
decimal = BigDecimal(amount.to_s, exception: false)
raise ArgumentError, "amount must be a finite decimal" unless decimal&.finite?
@kilograms = decimal * factor
freeze
end
def <=>(other)
kilograms <=> other.kilograms if other.is_a?(Measurement)
end
end
class MeasurementField < TypedEAV::Field::Base
value_column :decimal_value
operators :eq, :not_eq, :gt, :gteq, :lt, :lteq, :between, :is_null, :is_not_null
def cast(raw)
return [nil, false] if raw.nil? || (raw.is_a?(String) && raw.strip.empty?)
measurement = case raw
when Measurement then raw
when Hash
input = raw.transform_keys(&:to_s)
Measurement.new(input.fetch("amount"), unit: input.fetch("unit"))
when Numeric, String then Measurement.new(raw) # Scalars mean kilograms.
else return [nil, true]
end
[measurement.kilograms, false]
rescue ArgumentError, KeyError
[nil, true]
end
def read_value(record)
kilograms = record[:decimal_value]
Measurement.new(kilograms) unless kilograms.nil?
end
end
end
cast returns [BigDecimal, false] for valid input, [nil, false] for missing
input, and [nil, true] for invalid input. The inherited write_value and
apply_default already write that primitive to the single decimal cell. The
inherited cast_query_operand uses cast for scalar comparisons and each range
endpoint, so no separate query converter is needed.
Register the type in an initializer:
TypedEAV.configure do |config|
config.register_field_type :measurement_field, "Inventory::MeasurementField"
end
The registration key matches the class’s inferred field_type_name, so schema
export and import can resolve it. If your host uses a has_typed_eav types:
allowlist, include :measurement_field.
Write, read, and compare
For a Contact host with has_typed_eav and a global field definition:
field = Inventory::MeasurementField.create!(name: "mass", entity_type: "Contact")
contact = Contact.create!
contact.set_typed_eav_value(:mass, Inventory::Measurement.new("750", unit: "g"))
contact.save!
contact.reload.typed_eav_value(:mass).kilograms # => BigDecimal("0.75")
Contact.where_typed_eav(
{ name: :mass, op: :eq, value: Inventory::Measurement.new("0.75", unit: "kg") },
scope: nil,
) # => ActiveRecord::Relation including contact
lower = Inventory::Measurement.new("500", unit: "g")
upper = Inventory::Measurement.new("1", unit: "kg")
Contact.where_typed_eav({ name: :mass, op: :between, value: lower...upper }, scope: nil)
# Includes 0.5 kg and 0.75 kg; excludes 1 kg.
Use your application’s normal scope when defining and querying tenant fields;
scope: nil explicitly selects global definitions. Comparable lets Ruby build
measurement ranges before the field converts their endpoints. Inclusive,
exclusive, beginless, and endless ranges retain their Ruby boundaries.
Inputs, defaults, and failures
- Accepted inputs are a
Measurement, a hash containingamountandunit(string or symbol keys), or a numeric value/numeric string in kilograms. Use decimal strings when exact decimal input matters. - Hash units must explicitly be
"kg"or"g"; unsupported or missing units, malformed amounts, and non-finite numbers are invalid. Constructing an invalidMeasurementraisesArgumentError. Invalid field assignments fail validation with:invalid; invalid query operands raiseArgumentError. niland blank strings represent missing input. Arequired: truefield rejects them with:blank.- Store defaults as JSON-compatible hashes, for example
field.update!(default_value: { amount: "500", unit: "g" }). Invalid defaults fail field validation.field.default_valuereturns the cast primitive (BigDecimal("0.5")), while a Value created without avalue:argument reads back as aMeasurement. Explicitvalue: nilskips the default. Numeric kilogram input also letsbackfill_default!reuse the cast default. - Database precision and scale still bound the stored decimal. Reads always reflect the persisted kilogram value; this recipe does not preserve the input unit or add a unit-selection UI.
Value#value, typed_eav_value, and typed_eav_hash use read_value and return
measurement objects. APIs that query raw scalar columns, such as
distinct_typed_eav_values and numeric aggregates, return kilogram primitives.
Wrap those results explicitly when an application needs measurement objects.
If you add domain validation, remember that validate_typed_value receives the
logical object during Value validation and the cast primitive during default
validation. Normalize both to kilograms before applying bounds.
This approach uses the existing field extension hooks.
It does not install a custom Rails type on the shared decimal_value column,
which also stores unrelated decimal fields.