shivam091/unit_measurements-rails

View on GitHub
lib/unit_measurements/rails/active_record.rb

Summary

Maintainability
A
0 mins
Test Coverage
A
100%
# -*- encoding: utf-8 -*-
# -*- frozen_string_literal: true -*-
# -*- warn_indent: true -*-

# The +UnitMeasurements+ module provides functionality for handling unit
# measurements. It includes various classes and modules for persisting and
# retrieving measurements with their units.
#
# The module also offers support for integrating with Rails ActiveRecord models
# for handling unit measurements conveniently.
#
# @author {Harshal V. Ladhe}[https://shivam091.github.io/]
# @since 1.0.0
module UnitMeasurements
  module Rails
    # The +UnitMeasurements::Rails::ActiveRecord+ module enhances ActiveRecord
    # models by providing a convenient way to handle unit measurements. It
    # facilitates defining measurement attributes in models with specific unit
    # group support.
    #
    # @author {Harshal V. Ladhe}[https://shivam091.github.io/]
    # @since 1.0.0
    module ActiveRecord
      # @!scope class
      # Defines a _reader_ and _writer_ methods for the measurement attributes in
      # the +ActiveRecord+ model.
      #
      # @example Defining single measurement attribute:
      #   class Cube < ActiveRecord::Base
      #     measured UnitMeasurements::Length, :height
      #   end
      #
      # @example Defining multiple measurement attributes:
      #   class Package < ActiveRecord::Base
      #     measured UnitMeasurements::Weight, :item_weight, :total_weight
      #   end
      #
      # @param [Class|String] unit_group
      #   The unit group class or its name as a string.
      # @param [Array<String|Symbol>] measured_attrs
      #   An array of the names of measurement attributes.
      # @param [Hash] options A customizable set of options
      # @option options [String|Symbol] :quantity_attribute_name The name of the quantity attribute.
      # @option options [String|Symbol] :unit_attribute_name The name of the unit attribute.
      #
      # @return [void]
      #
      # @raise [BaseError]
      #   If +unit_group+ is not a subclass of +UnitMeasurements::Measurement+ or
      #   the attribute has already been measured.
      #
      # @see BaseError
      # @author {Harshal V. Ladhe}[https://shivam091.github.io/]
      # @since 1.0.0
      def measured(unit_group, *measured_attrs, **options)
        validate_unit_group!(unit_group)

        options = options.reverse_merge(quantity_attribute_name: nil, unit_attribute_name: nil)
        unit_group = unit_group.constantize if unit_group.is_a?(String)

        options[:unit_group] = unit_group

        measured_attrs.map(&:to_s).each do |measured_attr|
          raise BaseError, "The attribute '#{measured_attr}' has already been measured." if measured_attributes.key?(measured_attr)

          quantity_attr = options[:quantity_attribute_name]&.to_s || "#{measured_attr}_quantity"
          unit_attr = options[:unit_attribute_name]&.to_s || "#{measured_attr}_unit"

          measured_attributes[measured_attr] = options.merge(quantity_attribute_name: quantity_attr, unit_attribute_name: unit_attr)

          define_reader_for_measured_attr(measured_attr, quantity_attr, unit_attr, unit_group)
          define_writer_for_measured_attr(measured_attr, quantity_attr, unit_attr, unit_group)
          redefine_quantity_writer(quantity_attr)
          redefine_unit_writer(unit_attr, unit_group)
        end
      end

      # @!scope class
      # Returns a hash containing information about the measurement attributes
      # and their options.
      #
      # @return [Hash{String => Hash{Symbol => String|Class}}]
      #   A hash where keys represent the names of the measurement attributes,
      #   and values are hashes containing the options for each measurement
      #   attribute.
      #
      # @example
      #   {
      #     "height" => {
      #       unit_group: UnitMeasurements::Length,
      #       quantity_attribute_name: "height_quantity",
      #       unit_attribute_name: "height_unit"
      #     },
      #     "weight" => {
      #       unit_group: UnitMeasurements::Length,
      #       quantity_attribute_name: "weight_quantity",
      #       unit_attribute_name: "weight_unit"
      #     }
      #   }
      #
      # @author {Harshal V. Ladhe}[https://shivam091.github.io/]
      # @since 1.2.0
      def measured_attributes
        @measured_attributes ||= {}
      end

      private

      # @!scope class
      # @private
      # Validates whether +unit_group+ is a subclass of +UnitMeasurements::Measurement+.
      #
      # @param [Class] unit_group The unit group class to be validated.
      #
      # @raise [BaseError]
      #   if unit group is not a subclass of UnitMeasurements::Measurement.
      #
      # @return [void]
      #
      # @author {Harshal V. Ladhe}[https://shivam091.github.io/]
      # @since 1.0.0
      def validate_unit_group!(unit_group)
        unless unit_group.is_a?(Class) && unit_group.ancestors.include?(Measurement)
          raise BaseError, "Expecting `#{unit_group}` to be a subclass of UnitMeasurements::Measurement"
        end
      end

      # @!scope class
      # @private
      # Defines the method to _read_ the measurement attribute.
      #
      # @param [String] measured_attr The name of the measurement attribute.
      # @param [String] quantity_attr The name of the quantity attribute.
      # @param [String] unit_attr The name of the unit attribute.
      # @param [Class] unit_group The unit group class for the measurement.
      #
      # @return [void]
      #
      # @author {Harshal V. Ladhe}[https://shivam091.github.io/]
      # @since 1.0.0
      def define_reader_for_measured_attr(measured_attr, quantity_attr, unit_attr, unit_group)
        define_method(measured_attr) do
          column_exists?(quantity_attr) && column_exists?(unit_attr)

          quantity, unit = public_send(quantity_attr), public_send(unit_attr)

          begin
            unit_group.new(quantity, unit)
          rescue BlankQuantityError, BlankUnitError, ParseError, UnitError
            nil
          end
        end
      end

      # @!scope class
      # @private
      # Defines the method to _write_ the measurement attribute.
      #
      # @param [String] measured_attr The name of the measurement attribute.
      # @param [String] quantity_attr The name of the quantity attribute.
      # @param [String] unit_attr The name of the unit attribute.
      # @param [Class] unit_group The unit group class for the measurement.
      #
      # @return [void]
      #
      # @author {Harshal V. Ladhe}[https://shivam091.github.io/]
      # @since 1.0.0
      def define_writer_for_measured_attr(measured_attr, quantity_attr, unit_attr, unit_group)
        define_method("#{measured_attr}=") do |measurement|
          column_exists?(quantity_attr) && column_exists?(unit_attr)

          if measurement.is_a?(unit_group)
            public_send("#{quantity_attr}=", measurement.quantity)
            public_send("#{unit_attr}=", measurement.unit.name)
          else
            public_send("#{quantity_attr}=", nil)
            public_send("#{unit_attr}=", nil)
          end
        end
      end

      # @!scope class
      # @private
      # Redefines the _writer_ method to set the quantity attribute.
      #
      # @param quantity_attr [String] The name of the quantity attribute.
      #
      # @return [void]
      #
      # @author {Harshal V. Ladhe}[https://shivam091.github.io/]
      # @since 1.0.0
      def redefine_quantity_writer(quantity_attr)
        redefine_method("#{quantity_attr}=") do |quantity|
          column_exists?(quantity_attr)

          quantity = BigDecimal(quantity, Float::DIG) if quantity.is_a?(String)
          if quantity
            db_column_props = self.column_for_attribute(quantity_attr)
            precision, scale = db_column_props.precision, db_column_props.scale

            quantity.round(scale)
          else
            nil
          end.tap { |value| write_attribute(quantity_attr, value) }
        end
      end

      # @!scope class
      # @private
      # Redefines the _writer_ method to set the unit attribute.
      #
      # @param unit_attr [String] The name of the unit attribute.
      # @param unit_group [Class] The unit group class for the measurement.
      #
      # @return [void]
      #
      # @author {Harshal V. Ladhe}[https://shivam091.github.io/]
      # @since 1.0.0
      def redefine_unit_writer(unit_attr, unit_group)
        redefine_method("#{unit_attr}=") do |unit|
          column_exists?(unit_attr)

          unit_name = unit_group.unit_for(unit).try!(:name)
          write_attribute(unit_attr, (unit_name || unit))
        end
      end
    end
  end
end

# ActiveSupport hook to extend ActiveRecord with the `UnitMeasurements::Rails::ActiveRecord`
# module.
ActiveSupport.on_load(:active_record) do
  ::ActiveRecord::Base.send(:extend, UnitMeasurements::Rails::ActiveRecord)
end