TypedEAV Documentation GitHub

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 containing amount and unit (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 invalid Measurement raises ArgumentError. Invalid field assignments fail validation with :invalid; invalid query operands raise ArgumentError.
  • nil and blank strings represent missing input. A required: true field 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_value returns the cast primitive (BigDecimal("0.5")), while a Value created without a value: argument reads back as a Measurement. Explicit value: nil skips the default. Numeric kilogram input also lets backfill_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.