Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merge measured-rails into this gem #155

Merged
merged 2 commits into from
Apr 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
2 changes: 2 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
source 'https://rubygems.org'

gemspec

gem "activerecord"
116 changes: 113 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,116 @@ Measured::Weight.new("3.14", "kg").format(with_conversion_string: false)
> "3.14 kg"
```

### Active Record

This gem also provides an Active Record adapter for persisting and retrieving measurements with their units, and model validations.

Columns are expected to have the `_value` and `_unit` suffix, and be `DECIMAL` and `VARCHAR`, and defaults are accepted. Customizing the column used to hold units is supported, see below for details.

```ruby
class AddWeightAndLengthToThings < ActiveRecord::Migration
def change
add_column :things, :minimum_weight_value, :decimal, precision: 10, scale: 2
add_column :things, :minimum_weight_unit, :string, limit: 12

add_column :things, :total_length_value, :decimal, precision: 10, scale: 2, default: 0
add_column :things, :total_length_unit, :string, limit: 12, default: "cm"
end
end
```

A column can be declared as a measurement with its measurement subclass:

```ruby
class Thing < ActiveRecord::Base
measured Measured::Weight, :minimum_weight
measured Measured::Length, :total_length
measured Measured::Volume, :total_volume
end
```

You can optionally customize the model's unit column by specifying it in the `unit_field_name` option, as follows:

```ruby
class ThingWithCustomUnitAccessor < ActiveRecord::Base
measured_length :length, :width, :height, unit_field_name: :size_unit
measured_weight :total_weight, :extra_weight, unit_field_name: :weight_unit
measured_volume :total_volume, :extra_volume, unit_field_name: :volume_unit
end
```

Similarly, you can optionally customize the model's value column by specifying it in the `value_field_name` option, as follows:

```ruby
class ThingWithCustomValueAccessor < ActiveRecord::Base
measured_length :length, value_field_name: :custom_length
measured_weight :total_weight, value_field_name: :custom_weight
measured_volume :volume, value_field_name: :custom_volume
end
```

There are some simpler methods for predefined types:

```ruby
class Thing < ActiveRecord::Base
measured_weight :minimum_weight
measured_length :total_length
measured_volume :total_volume
end
```

This will allow you to access and assign a measurement object:

```ruby
thing = Thing.new
thing.minimum_weight = Measured::Weight.new(10, "g")
thing.minimum_weight_unit # "g"
thing.minimum_weight_value # 10
```

Order of assignment does not matter, and each property can be assigned separately and with mass assignment:

```ruby
params = { total_length_unit: "cm", total_length_value: "3" }
thing = Thing.new(params)
thing.total_length.to_s # 3 cm
```

### Validations

Validations are available:

```ruby
class Thing < ActiveRecord::Base
measured_length :total_length

validates :total_length, measured: true
end
```

This will validate that the unit is defined on the measurement, and that there is a value.

Rather than `true` the validation can accept a hash with the following options:

* `message`: Override the default "is invalid" message.
* `units`: A subset of units available for this measurement. Units must be in existing measurement.
* `greater_than`
* `greater_than_or_equal_to`
* `equal_to`
* `less_than`
* `less_than_or_equal_to`

All comparison validations require `Measured::Measurable` values, not scalars. Most of these options replace the `numericality` validator which compares the measurement/method name/proc to the column's value. Validations can also be combined with `presence` validator.

**Note:** Validations are strongly recommended since assigning an invalid unit will cause the measurement to return `nil`, even if there is a value:

```ruby
thing = Thing.new
thing.total_length_value = 1
thing.total_length_unit = "invalid"
thing.total_length # nil
```

## Units and conversions

### SI units support
Expand Down Expand Up @@ -269,7 +379,7 @@ Existing alternatives which were considered:
* **Cons**
* Opens up and modifies `Array`, `Date`, `Fixnum`, `Math`, `Numeric`, `String`, `Time`, and `Object`, then depends on those changes internally.
* Lots of code to solve a relatively simple problem.
* No ActiveRecord adapter.
* No Active Record adapter.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😬


### Gem: [quantified](https://github.com/Shopify/quantified)
* **Pros**
Expand All @@ -278,7 +388,7 @@ Existing alternatives which were considered:
* All math done with floats making it highly lossy.
* All units assumed to be pluralized, meaning using unit abbreviations is not possible.
* Not actively maintained.
* No ActiveRecord adapter.
* No Active Record adapter.

### Gem: [unitwise](https://github.com/joshwlewis/unitwise)
* **Pros**
Expand All @@ -287,7 +397,7 @@ Existing alternatives which were considered:
* **Cons**
* Lots of code. Good code, but lots of it.
* Many modifications to core types.
* ActiveRecord adapter exists but is written and maintained by a different person/org.
* Active Record adapter exists but is written and maintained by a different person/org.
* Not actively maintained.

## Contributing
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ source 'https://rubygems.org'
gemspec path: '..'

gem 'activesupport', '~> 6.0'
gem "activerecord", '~> 6.0'
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ source 'https://rubygems.org'
gemspec path: '..'

gem 'activesupport', '~> 6.1'
gem "activerecord", '~> 6.1'
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ source 'https://rubygems.org'
gemspec path: '..'

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

gemspec path: '..'

gem 'activesupport', github: 'rails/rails', branch: 'main'
gem 'activerecord', github: 'rails/rails', branch: 'main'
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,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was like this before, but I think we can lazy load these. I'll make a note to look at this later.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I am doing a more or less straight port for now, and trying to not do any incidental changes.

It would definitely be great if we can do the lazy loading.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's how measured works, or at least optionally. But seeing this in one place makes me realize that we don't allow that if you're using measured-rails. I'll put it on my backlog.

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
Loading
Loading