Skip to content

Commit

Permalink
Merge measured-rails into this gem
Browse files Browse the repository at this point in the history
  • Loading branch information
paracycle committed Apr 15, 2024
1 parent e61ffe9 commit cef61ac
Show file tree
Hide file tree
Showing 27 changed files with 1,160 additions and 3 deletions.
11 changes: 8 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,14 @@ jobs:
- '3.2'
gemfile:
- Gemfile
- gemfiles/activesupport-6.0.gemfile
- gemfiles/activesupport-6.1.gemfile
- gemfiles/activesupport-7.0.gemfile
- gemfiles/rails-6.0.gemfile
- gemfiles/rails-6.1.gemfile
- gemfiles/rails-7.0.gemfile
- gemfiles/rails-edge.gemfile
exclude:
# Rails Edge only supports Ruby >= 3.1
- ruby: '3.0'
gemfile: gemfiles/rails-edge.gemfile

name: Ruby ${{ matrix.ruby }} ${{ matrix.gemfile }}
steps:
Expand Down
4 changes: 4 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
source 'https://rubygems.org'

gemspec

gem "activerecord"
gem "combustion"
gem "sqlite3"
8 changes: 8 additions & 0 deletions gemfiles/rails-6.0.gemfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
source 'https://rubygems.org'

gemspec path: '..'

gem 'activesupport', '~> 6.0'
gem "activerecord", '~> 6.0'
gem "combustion"
gem "sqlite3"
8 changes: 8 additions & 0 deletions gemfiles/rails-6.1.gemfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
source 'https://rubygems.org'

gemspec path: '..'

gem 'activesupport', '~> 6.1'
gem "activerecord", '~> 6.1'
gem "combustion"
gem "sqlite3"
8 changes: 8 additions & 0 deletions gemfiles/rails-7.0.gemfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
source 'https://rubygems.org'

gemspec path: '..'

gem 'activesupport', '~> 7.0'
gem 'activerecord', '~> 7.0'
gem "combustion"
gem "sqlite3"
8 changes: 8 additions & 0 deletions gemfiles/rails-edge.gemfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
source 'https://rubygems.org'

gemspec path: '..'

gem 'activesupport', github: 'rails/rails', branch: 'main'
gem 'activerecord', github: 'rails/rails', branch: 'main'
gem "combustion"
gem "sqlite3"
2 changes: 2 additions & 0 deletions lib/measured.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@
require "measured/units/length"
require "measured/units/weight"
require "measured/units/volume"

require "measured/railtie" if defined?(Rails::Railtie)
130 changes: 130 additions & 0 deletions lib/measured/rails/active_record.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# frozen_string_literal: true

module Measured::Rails::ActiveRecord
extend ActiveSupport::Concern

module ClassMethods
def measured(measured_class, *fields)
options = fields.extract_options!
options = {}.merge(options)

measured_class = measured_class.constantize if measured_class.is_a?(String)
unless measured_class.is_a?(Class) && measured_class.ancestors.include?(Measured::Measurable)
raise Measured::Rails::Error, "Expecting #{ measured_class } to be a subclass of Measured::Measurable"
end

options[:class] = measured_class

fields.map(&:to_sym).each do |field|
raise Measured::Rails::Error, "The field #{ field } has already been measured" if measured_fields.key?(field)

measured_fields[field] = options

unit_field_name = if options[:unit_field_name]
measured_fields[field][:unit_field_name] = options[:unit_field_name].to_s
else
"#{ field }_unit"
end

value_field_name = if options[:value_field_name]
measured_fields[field][:value_field_name] = options[:value_field_name].to_s
else
"#{ field }_value"
end

# Reader to retrieve measured object
define_method(field) do
value = public_send(value_field_name)
unit = public_send(unit_field_name)

return nil unless value && unit

instance = instance_variable_get("@measured_#{ field }") if instance_variable_defined?("@measured_#{ field }")
new_instance = begin
measured_class.new(value, unit)
rescue Measured::UnitError
nil
end

if instance == new_instance
instance
else
instance_variable_set("@measured_#{ field }", new_instance)
end
end

# Writer to assign measured object
define_method("#{ field }=") do |incoming|
if incoming.is_a?(measured_class)
instance_variable_set("@measured_#{ field }", incoming)
precision = self.column_for_attribute(value_field_name).precision
scale = self.column_for_attribute(value_field_name).scale
rounded_to_scale_value = incoming.value.round(scale)

max = self.class.measured_fields[field][:max_on_assignment]
if max && rounded_to_scale_value > max
rounded_to_scale_value = max
elsif rounded_to_scale_value.to_i.to_s.length > (precision - scale)
raise Measured::Rails::Error, "The value #{rounded_to_scale_value} being set for column '#{value_field_name}' has too many significant digits. Please ensure it has no more than #{precision - scale} significant digits."
end

public_send("#{ value_field_name }=", rounded_to_scale_value)
public_send("#{ unit_field_name }=", incoming.unit.name)
else
instance_variable_set("@measured_#{ field }", nil)
public_send("#{ value_field_name}=", nil)
public_send("#{ unit_field_name }=", nil)
end
end

# Writer to override unit assignment
redefine_method("#{ unit_field_name }=") do |incoming|
unit_name = measured_class.unit_system.unit_for(incoming).try!(:name)
write_attribute(unit_field_name, unit_name || incoming)
end
end
end

def measured_fields
@measured_fields ||= {}
end

end

module Length
extend ActiveSupport::Concern

module ClassMethods
def measured_length(*fields)
measured(Measured::Length, *fields)
end
end
end

module Volume
extend ActiveSupport::Concern

module ClassMethods
def measured_volume(*fields)
measured(Measured::Volume, *fields)
end
end
end

module Weight
extend ActiveSupport::Concern

module ClassMethods
def measured_weight(*fields)
measured(Measured::Weight, *fields)
end
end
end
end

::ActiveRecord::Base.include(
Measured::Rails::ActiveRecord,
Measured::Rails::ActiveRecord::Length,
Measured::Rails::ActiveRecord::Volume,
Measured::Rails::ActiveRecord::Weight,
)
68 changes: 68 additions & 0 deletions lib/measured/rails/validations.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# frozen_string_literal: true

require "active_model/validations"

class MeasuredValidator < ActiveModel::EachValidator
CHECKS = {
greater_than: :>,
greater_than_or_equal_to: :>=,
equal_to: :==,
less_than: :<,
less_than_or_equal_to: :<=,
}.freeze

def validate_each(record, attribute, measurable)
measured_config = record.class.measured_fields[attribute]
unit_field_name = measured_config[:unit_field_name] || "#{ attribute }_unit"
value_field_name = measured_config[:value_field_name] || "#{ attribute }_value"

measured_class = measured_config[:class]

measurable_unit_name = record.public_send(unit_field_name)
measurable_value = record.public_send(value_field_name)

return unless measurable_unit_name.present? || measurable_value.present?

measurable_unit = measured_class.unit_system.unit_for(measurable_unit_name)
record.errors.add(attribute, message(record, "is not a valid unit")) unless measurable_unit

if options[:units] && measurable_unit.present?
valid_units = Array(options[:units]).map { |unit| measured_class.unit_system.unit_for(unit) }
record.errors.add(attribute, message(record, "is not a valid unit")) unless valid_units.include?(measurable_unit)
end

if measurable_unit && measurable_value.present?
options.slice(*CHECKS.keys).each do |option, value|
comparable_value = value_for(value, record)
comparable_value = measured_class.new(comparable_value, measurable_unit) unless comparable_value.is_a?(Measured::Measurable)
unless measurable.public_send(CHECKS[option], comparable_value)
record.errors.add(attribute, message(record, "#{measurable.to_s} must be #{CHECKS[option]} #{comparable_value}"))
end
end
end
end

private

def message(record, default_message)
if options[:message].respond_to?(:call)
options[:message].call(record)
else
options[:message] || default_message
end
end

def value_for(key, record)
value = case key
when Proc
key.call(record)
when Symbol
record.send(key)
else
key
end

raise ArgumentError, ":#{ value } must be a number or a Measurable object" unless (value.is_a?(Numeric) || value.is_a?(Measured::Measurable))
value
end
end
12 changes: 12 additions & 0 deletions lib/measured/railtie.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# frozen_string_literal: true

module Measured
class Rails < ::Rails::Railtie
class Error < StandardError ; end

ActiveSupport.on_load(:active_record) do
require "measured/rails/active_record"
require "measured/rails/validations"
end
end
end
14 changes: 14 additions & 0 deletions test/internal/app/models/thing.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# frozen_string_literal: true
class Thing < ActiveRecord::Base

measured_length :length, :width

measured Measured::Length, :height
measured Measured::Volume, :volume

measured_weight :total_weight

measured "Measured::Weight", :extra_weight

measured_length :length_with_max_on_assignment, {max_on_assignment: 500}
end
18 changes: 18 additions & 0 deletions test/internal/app/models/thing_with_custom_unit_accessor.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# frozen_string_literal: true
class ThingWithCustomUnitAccessor < ActiveRecord::Base
measured_length :length, :width, unit_field_name: :size_unit
validates :length, measured: true
validates :width, measured: true

measured_volume :volume
validates :volume, measured: true

measured Measured::Length, :height, unit_field_name: :size_unit
validates :height, measured: true

measured_weight :total_weight, unit_field_name: :weight_unit
validates :total_weight, measured: true

measured "Measured::Weight", :extra_weight, unit_field_name: :weight_unit
validates :extra_weight, measured: true
end
19 changes: 19 additions & 0 deletions test/internal/app/models/thing_with_custom_value_accessor.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# frozen_string_literal: true
class ThingWithCustomValueAccessor < ActiveRecord::Base
measured_length :length, value_field_name: :custom_length
validates :length, measured: true
measured_length :width, value_field_name: :custom_width
validates :width, measured: true

measured_volume :volume, value_field_name: :custom_volume
validates :volume, measured: true

measured_length :height, value_field_name: :custom_height
validates :height, measured: true

measured_weight :total_weight, value_field_name: :custom_weight
validates :total_weight, measured: true

measured_weight :extra_weight, value_field_name: :custom_extra_weight
validates :extra_weight, measured: true
end
45 changes: 45 additions & 0 deletions test/internal/app/models/validated_thing.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# frozen_string_literal: true
class ValidatedThing < ActiveRecord::Base
measured_length :length
validates :length, measured: true

measured_length :length_true
validates :length_true, measured: true

measured_length :length_message
validates :length_message, measured: {message: "has a custom failure message"}

measured_length :length_message_from_block
validates :length_message_from_block, measured: { message: Proc.new { |record| "#{record.length_message_from_block_unit} is not a valid unit" } }

measured_length :length_units
validates :length_units, measured: {units: [:meter, "cm"]}

measured_length :length_units_singular
validates :length_units_singular, measured: {units: :ft, message: "custom message too"}

measured_length :length_presence
validates :length_presence, measured: true, presence: true

measured_length :length_numericality_inclusive
validates :length_numericality_inclusive, measured: {greater_than_or_equal_to: :low_bound, less_than_or_equal_to: :high_bound }

measured_length :length_numericality_exclusive
validates :length_numericality_exclusive, measured: {greater_than: Measured::Length.new(3, :m), less_than: Measured::Length.new(500, :cm), message: "is super not ok"}

measured_length :length_numericality_equality
validates :length_numericality_equality, measured: {equal_to: Proc.new { Measured::Length.new(100, :cm) }, message: "must be exactly 100cm"}

measured_length :length_invalid_comparison
validates :length_invalid_comparison, measured: {equal_to: "not_a_measured_subclass"}

private

def low_bound
Measured::Length.new(10, :in)
end

def high_bound
Measured::Length.new(20, :in)
end
end
9 changes: 9 additions & 0 deletions test/internal/config.ru
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# frozen_string_literal: true

require "rubygems"
require "bundler"

Bundler.require :default, :development

Combustion.initialize! :all
run Combustion::Application
3 changes: 3 additions & 0 deletions test/internal/config/database.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
test:
adapter: sqlite3
database: "db/measured.sqlite3"
Binary file added test/internal/db/foo.sqlite3
Binary file not shown.
Binary file added test/internal/db/foo.sqlite3-shm
Binary file not shown.
Binary file added test/internal/db/foo.sqlite3-wal
Binary file not shown.
Binary file added test/internal/db/measured.sqlite3
Binary file not shown.
Binary file added test/internal/db/measured.sqlite3-shm
Binary file not shown.
Binary file added test/internal/db/measured.sqlite3-wal
Binary file not shown.
Loading

0 comments on commit cef61ac

Please sign in to comment.