From 120e36b1a5b7547a335f85d5e568bcea20ff7196 Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Mon, 15 Apr 2024 22:55:29 +0300 Subject: [PATCH 1/2] Merge measured-rails into this gem --- .github/workflows/ci.yml | 11 +- Gemfile | 2 + ...esupport-6.0.gemfile => rails-6.0.gemfile} | 1 + ...esupport-6.1.gemfile => rails-6.1.gemfile} | 1 + ...esupport-7.0.gemfile => rails-7.0.gemfile} | 1 + gemfiles/rails-edge.gemfile | 6 + lib/measured.rb | 2 + lib/measured/rails/active_record.rb | 130 ++++++ lib/measured/rails/validations.rb | 68 +++ lib/measured/railtie.rb | 12 + lib/tapioca/dsl/compilers/measured_rails.rb | 108 +++++ measured.gemspec | 3 + test/internal/app/models/thing.rb | 14 + .../models/thing_with_custom_unit_accessor.rb | 18 + .../thing_with_custom_value_accessor.rb | 19 + test/internal/app/models/validated_thing.rb | 45 ++ test/internal/config.ru | 9 + test/internal/config/database.yml | 3 + test/internal/db/.gitignore | 1 + test/internal/db/schema.rb | 99 ++++ test/internal/log/.gitignore | 1 + test/rails/active_record_test.rb | 433 ++++++++++++++++++ test/rails/validation_test.rb | 252 ++++++++++ .../dsl/compilers/measured_rails_test.rb | 220 +++++++++ test/test_helper.rb | 15 + 25 files changed, 1471 insertions(+), 3 deletions(-) rename gemfiles/{activesupport-6.0.gemfile => rails-6.0.gemfile} (73%) rename gemfiles/{activesupport-6.1.gemfile => rails-6.1.gemfile} (73%) rename gemfiles/{activesupport-7.0.gemfile => rails-7.0.gemfile} (73%) create mode 100644 gemfiles/rails-edge.gemfile create mode 100644 lib/measured/rails/active_record.rb create mode 100644 lib/measured/rails/validations.rb create mode 100644 lib/measured/railtie.rb create mode 100644 lib/tapioca/dsl/compilers/measured_rails.rb create mode 100644 test/internal/app/models/thing.rb create mode 100644 test/internal/app/models/thing_with_custom_unit_accessor.rb create mode 100644 test/internal/app/models/thing_with_custom_value_accessor.rb create mode 100644 test/internal/app/models/validated_thing.rb create mode 100644 test/internal/config.ru create mode 100644 test/internal/config/database.yml create mode 100644 test/internal/db/.gitignore create mode 100644 test/internal/db/schema.rb create mode 100644 test/internal/log/.gitignore create mode 100644 test/rails/active_record_test.rb create mode 100644 test/rails/validation_test.rb create mode 100644 test/tapioca/dsl/compilers/measured_rails_test.rb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8dcda58..d160576 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/Gemfile b/Gemfile index fa75df1..0bbb60f 100644 --- a/Gemfile +++ b/Gemfile @@ -1,3 +1,5 @@ source 'https://rubygems.org' gemspec + +gem "activerecord" diff --git a/gemfiles/activesupport-6.0.gemfile b/gemfiles/rails-6.0.gemfile similarity index 73% rename from gemfiles/activesupport-6.0.gemfile rename to gemfiles/rails-6.0.gemfile index c216c44..9330bfa 100644 --- a/gemfiles/activesupport-6.0.gemfile +++ b/gemfiles/rails-6.0.gemfile @@ -3,3 +3,4 @@ source 'https://rubygems.org' gemspec path: '..' gem 'activesupport', '~> 6.0' +gem "activerecord", '~> 6.0' diff --git a/gemfiles/activesupport-6.1.gemfile b/gemfiles/rails-6.1.gemfile similarity index 73% rename from gemfiles/activesupport-6.1.gemfile rename to gemfiles/rails-6.1.gemfile index b0ace85..0c93be8 100644 --- a/gemfiles/activesupport-6.1.gemfile +++ b/gemfiles/rails-6.1.gemfile @@ -3,3 +3,4 @@ source 'https://rubygems.org' gemspec path: '..' gem 'activesupport', '~> 6.1' +gem "activerecord", '~> 6.1' diff --git a/gemfiles/activesupport-7.0.gemfile b/gemfiles/rails-7.0.gemfile similarity index 73% rename from gemfiles/activesupport-7.0.gemfile rename to gemfiles/rails-7.0.gemfile index b8eda1d..68da491 100644 --- a/gemfiles/activesupport-7.0.gemfile +++ b/gemfiles/rails-7.0.gemfile @@ -3,3 +3,4 @@ source 'https://rubygems.org' gemspec path: '..' gem 'activesupport', '~> 7.0' +gem 'activerecord', '~> 7.0' diff --git a/gemfiles/rails-edge.gemfile b/gemfiles/rails-edge.gemfile new file mode 100644 index 0000000..25596be --- /dev/null +++ b/gemfiles/rails-edge.gemfile @@ -0,0 +1,6 @@ +source 'https://rubygems.org' + +gemspec path: '..' + +gem 'activesupport', github: 'rails/rails', branch: 'main' +gem 'activerecord', github: 'rails/rails', branch: 'main' diff --git a/lib/measured.rb b/lib/measured.rb index e407309..7180d66 100644 --- a/lib/measured.rb +++ b/lib/measured.rb @@ -4,3 +4,5 @@ require "measured/units/length" require "measured/units/weight" require "measured/units/volume" + +require "measured/railtie" if defined?(Rails::Railtie) diff --git a/lib/measured/rails/active_record.rb b/lib/measured/rails/active_record.rb new file mode 100644 index 0000000..4b93d99 --- /dev/null +++ b/lib/measured/rails/active_record.rb @@ -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, +) diff --git a/lib/measured/rails/validations.rb b/lib/measured/rails/validations.rb new file mode 100644 index 0000000..5e93498 --- /dev/null +++ b/lib/measured/rails/validations.rb @@ -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 diff --git a/lib/measured/railtie.rb b/lib/measured/railtie.rb new file mode 100644 index 0000000..e4c1305 --- /dev/null +++ b/lib/measured/railtie.rb @@ -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 diff --git a/lib/tapioca/dsl/compilers/measured_rails.rb b/lib/tapioca/dsl/compilers/measured_rails.rb new file mode 100644 index 0000000..7bf8ad2 --- /dev/null +++ b/lib/tapioca/dsl/compilers/measured_rails.rb @@ -0,0 +1,108 @@ +# typed: strict +# frozen_string_literal: true + +return unless defined?(::Measured::Rails::ActiveRecord) + +module Tapioca + module Dsl + module Compilers + # `Tapioca::Dsl::Compilers::MeasuredRails` refines RBI files for subclasses of + # [`ActiveRecord::Base`](https://api.rubyonrails.org/classes/ActiveRecord/Base.html) + # that utilize the [`measured-rails`](https://github.com/shopify/measured-rails) DSL. + # This compiler is only responsible for defining the methods that would be created + # for measured fields that are defined in the Active Record model. + # + # For example, with the following model class: + # + # ~~~rb + # class Package < ActiveRecord::Base + # measured Measured::Weight, :minimum_weight + # measured Measured::Length, :total_length + # measured Measured::Volume, :total_volume + # end + # ~~~ + # + # this compiler will produce the following methods in the RBI file + # `package.rbi`: + # + # ~~~rbi + # # package.rbi + # # typed: true + # + # class Package + # include GeneratedMeasuredRailsMethods + # + # module GeneratedMeasuredRailsMethods + # sig { returns(T.nilable(Measured::Weight)) } + # def minimum_weight; end + # + # sig { params(value: T.nilable(Measured::Weight)).void } + # def minimum_weight=(value); end + # + # sig { returns(T.nilable(Measured::Length)) } + # def total_length; end + # + # sig { params(value: T.nilable(Measured::Length)).void } + # def total_length=(value); end + # + # sig { returns(T.nilable(Measured::Volume)) } + # def total_volume; end + # + # sig { params(value: T.nilable(Measured::Volume)).void } + # def total_volume=(value); end + # end + # end + # ~~~ + class MeasuredRails < ::Tapioca::Dsl::Compiler + extend T::Sig + + ConstantType = type_member { { + fixed: T.all( + T.class_of(::ActiveRecord::Base), + ::Measured::Rails::ActiveRecord::ClassMethods + ) + } } + + MeasuredMethodsModuleName = T.let("GeneratedMeasuredRailsMethods", String) + + sig { override.void } + def decorate + return if constant.measured_fields.empty? + + root.create_path(constant) do |model| + model.create_module(MeasuredMethodsModuleName) do |mod| + populate_measured_methods(mod) + end + + model.create_include(MeasuredMethodsModuleName) + end + end + + sig { override.returns(T::Enumerable[Module]) } + def self.gather_constants + descendants_of(::ActiveRecord::Base) + end + + private + + sig { params(model: RBI::Scope).void } + def populate_measured_methods(model) + constant.measured_fields.each do |field, attrs| + klass = attrs[:class].to_s + + model.create_method( + field.to_s, + return_type: as_nilable_type(klass) + ) + + model.create_method( + "#{field}=", + parameters: [create_param("value", type: as_nilable_type(klass))], + return_type: "void" + ) + end + end + end + end + end +end diff --git a/measured.gemspec b/measured.gemspec index b0019da..fb26e1c 100644 --- a/measured.gemspec +++ b/measured.gemspec @@ -33,4 +33,7 @@ Gem::Specification.new do |spec| spec.add_development_dependency "minitest-reporters" spec.add_development_dependency "mocha", ">= 1.4.0" spec.add_development_dependency "pry" + spec.add_development_dependency "combustion" + spec.add_development_dependency "sqlite3" + spec.add_development_dependency "tapioca" end diff --git a/test/internal/app/models/thing.rb b/test/internal/app/models/thing.rb new file mode 100644 index 0000000..c8ef8f2 --- /dev/null +++ b/test/internal/app/models/thing.rb @@ -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 diff --git a/test/internal/app/models/thing_with_custom_unit_accessor.rb b/test/internal/app/models/thing_with_custom_unit_accessor.rb new file mode 100644 index 0000000..c3e3fee --- /dev/null +++ b/test/internal/app/models/thing_with_custom_unit_accessor.rb @@ -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 diff --git a/test/internal/app/models/thing_with_custom_value_accessor.rb b/test/internal/app/models/thing_with_custom_value_accessor.rb new file mode 100644 index 0000000..546697b --- /dev/null +++ b/test/internal/app/models/thing_with_custom_value_accessor.rb @@ -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 diff --git a/test/internal/app/models/validated_thing.rb b/test/internal/app/models/validated_thing.rb new file mode 100644 index 0000000..aad5c47 --- /dev/null +++ b/test/internal/app/models/validated_thing.rb @@ -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 diff --git a/test/internal/config.ru b/test/internal/config.ru new file mode 100644 index 0000000..1265db9 --- /dev/null +++ b/test/internal/config.ru @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "rubygems" +require "bundler" + +Bundler.require :default, :development + +Combustion.initialize! :all +run Combustion::Application diff --git a/test/internal/config/database.yml b/test/internal/config/database.yml new file mode 100644 index 0000000..baddba5 --- /dev/null +++ b/test/internal/config/database.yml @@ -0,0 +1,3 @@ +test: + adapter: sqlite3 + database: ":memory:" diff --git a/test/internal/db/.gitignore b/test/internal/db/.gitignore new file mode 100644 index 0000000..fe38d65 --- /dev/null +++ b/test/internal/db/.gitignore @@ -0,0 +1 @@ +measured.sqlite* diff --git a/test/internal/db/schema.rb b/test/internal/db/schema.rb new file mode 100644 index 0000000..85a0e3a --- /dev/null +++ b/test/internal/db/schema.rb @@ -0,0 +1,99 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# Note that this schema.rb definition is the authoritative source for your +# database schema. If you need to create the application database on another +# system, you should be using db:schema:load, not running all the migrations +# from scratch. The latter is a flawed and unsustainable approach (the more migrations +# you'll amass, the slower it'll run and the greater likelihood for issues). +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema.define do + create_table "thing_with_custom_unit_accessors", force: :cascade do |t| + t.decimal "length_value", precision: 10, scale: 2 + t.decimal "width_value", precision: 10, scale: 2 + t.decimal "height_value", precision: 10, scale: 2 + t.decimal "volume_value", precision: 10, scale: 2 + t.string "volume_unit", limit: 12 + t.string "size_unit", limit: 12 + t.decimal "total_weight_value", precision: 10, scale: 2, default: "10.0" + t.decimal "extra_weight_value", precision: 10, scale: 2 + t.string "weight_unit", limit: 12 + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + create_table "things", force: :cascade do |t| + t.decimal "length_value", precision: 10, scale: 2 + t.string "length_unit", limit: 12 + t.decimal "width_value", precision: 10, scale: 2 + t.string "width_unit", limit: 12 + t.decimal "height_value", precision: 10, scale: 2 + t.string "height_unit", limit: 12 + t.decimal "volume_value", precision: 10, scale: 2 + t.string "volume_unit", limit: 12 + t.decimal "total_weight_value", precision: 10, scale: 2, default: "10.0" + t.string "total_weight_unit", limit: 12, default: "g" + t.decimal "extra_weight_value", precision: 10, scale: 2 + t.string "extra_weight_unit", limit: 12 + t.decimal "length_with_max_on_assignment_value", precision: 10, scale: 2 + t.string "length_with_max_on_assignment_unit", limit: 12 + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + create_table "validated_things", force: :cascade do |t| + t.decimal "length_value", precision: 10, scale: 2 + t.string "length_unit", limit: 12 + t.decimal "length_true_value", precision: 10, scale: 2 + t.string "length_true_unit", limit: 12 + t.decimal "length_message_value", precision: 10, scale: 2 + t.string "length_message_unit", limit: 12 + t.decimal "length_message_from_block_value", precision: 10, scale: 2 + t.string "length_message_from_block_unit", limit: 12 + t.decimal "length_units_value", precision: 10, scale: 2 + t.string "length_units_unit", limit: 12 + t.decimal "length_units_singular_value", precision: 10, scale: 2 + t.string "length_units_singular_unit", limit: 12 + t.decimal "length_presence_value", precision: 10, scale: 2 + t.string "length_presence_unit", limit: 12 + t.decimal "length_invalid_value", precision: 10, scale: 2 + t.string "length_invalid_unit", limit: 12 + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.decimal "length_numericality_inclusive_value", precision: 10, scale: 2 + t.string "length_numericality_inclusive_unit", limit: 12 + t.decimal "length_numericality_exclusive_value", precision: 10, scale: 2 + t.string "length_numericality_exclusive_unit", limit: 12 + t.decimal "length_numericality_equality_value", precision: 10, scale: 2 + t.string "length_numericality_equality_unit", limit: 12 + t.decimal "length_invalid_comparison_value", precision: 10, scale: 2 + t.string "length_invalid_comparison_unit", limit: 12 + t.decimal "length_non_zero_scalar_value", precision: 10, scale: 2 + t.string "length_non_zero_scalar_unit", limit: 12 + t.decimal "length_zero_scalar_value", precision: 10, scale: 2 + t.string "length_zero_scalar_unit", limit: 12 + t.decimal "length_numericality_less_than_than_scalar_value", precision: 10, scale: 2 + t.string "length_numericality_less_than_than_scalar_unit", limit: 12 + end + + create_table "thing_with_custom_value_accessors", force: :cascade do |t| + t.decimal "custom_length", precision: 10, scale: 2 + t.string "length_unit", limit: 12 + t.decimal "custom_width", precision: 10, scale: 2 + t.string "width_unit", limit: 12 + t.decimal "custom_height", precision: 10, scale: 2 + t.string "height_unit", limit: 12 + t.decimal "custom_volume", precision: 10, scale: 2 + t.string "volume_unit", limit: 12 + t.decimal "custom_weight", precision: 10, scale: 2, default: "10.0" + t.string "total_weight_unit", limit: 12 + t.decimal "custom_extra_weight", precision: 10, scale: 2 + t.string "extra_weight_unit", limit: 12 + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + +end diff --git a/test/internal/log/.gitignore b/test/internal/log/.gitignore new file mode 100644 index 0000000..bf0824e --- /dev/null +++ b/test/internal/log/.gitignore @@ -0,0 +1 @@ +*.log \ No newline at end of file diff --git a/test/rails/active_record_test.rb b/test/rails/active_record_test.rb new file mode 100644 index 0000000..173ec59 --- /dev/null +++ b/test/rails/active_record_test.rb @@ -0,0 +1,433 @@ +# frozen_string_literal: true +require "test_helper" + +class Measured::Rails::ActiveRecordTest < ActiveSupport::TestCase + setup do + reset_db + end + + test ".measured raises if called with something that isn't a Measured::Measurable" do + assert_raises Measured::Rails::Error do + Thing.measured(Object, :field) + end + end + + test ".measured raises if called with something that isn't a class" do + assert_raises Measured::Rails::Error do + Thing.measured(:not_correct, :field) + end + end + + test ".measured raises if you attempt to define a field twice" do + assert_raises Measured::Rails::Error do + Thing.measured Measured::Length, :height + end + end + + test ".measured defines a reader for the field" do + assert_equal length, thing.length + end + + test ".measured defines a writer for the field that returns" do + assert_equal new_length, thing.length=(new_length) + end + + test ".measured_fields returns the configuration for all measured fields on the class" do + expected = { + length: { class: Measured::Length }, + width: { class: Measured::Length }, + height: { class: Measured::Length }, + volume: { class: Measured::Volume }, + total_weight: { class: Measured::Weight }, + extra_weight: { class: Measured::Weight }, + length_with_max_on_assignment: { max_on_assignment: 500, class: Measured::Length } + } + + assert_equal expected, Thing.measured_fields + end + + test "reader returns the exact same object if the values are equivalent" do + thing.length = new_length + assert_equal new_length.object_id, thing.length.object_id + end + + test "reader creates an instance from the _value and _unit columns" do + thing = Thing.new + thing.width_value = 23 + thing.width_unit = "ft" + assert_equal Measured::Length.new(23, :ft), thing.width + end + + test "reader creates creating an instance from columns caches the same object" do + thing = Thing.new + thing.width_value = 23 + thing.width_unit = "ft" + assert_equal thing.width.object_id, thing.width.object_id + end + + test "reader deals with only the _value column set" do + thing = Thing.new + thing.width_value = 23 + assert_nil thing.width + end + + test "reader deals with only the _unit column set" do + thing = Thing.new + thing.width_unit = "cm" + assert_nil thing.width + end + + test "reader deals with nil-ing out the _value column" do + thing.width_value = nil + assert_nil thing.width + end + + test "reader deals with nil-ing out the _unit column" do + thing.width_unit = nil + assert_nil thing.width + end + + test "writer sets the value to nil if it is an incompatible object" do + thing.length = Object.new + assert_nil thing.length + end + + test "writer assigning nil blanks out the unit and value columns" do + thing.width = nil + assert_nil thing.width + assert_nil thing.width_unit + assert_nil thing.width_value + end + + test "assigning an invalid _unit sets the column but the measurable object is nil" do + thing.width_unit = "invalid" + assert_nil thing.width + assert_equal "invalid", thing.width_unit + end + + test "assigning an invalid _unit sets the column but the measurable object is nil and there is validation on the column" do + validated_thing.length_unit = "invalid" + validated_thing.valid? + assert_nil validated_thing.length + assert_equal "invalid", validated_thing.length_unit + end + + test "assigning a valid _unit sets it" do + thing.width_unit = :mm + assert_equal thing.width, Measured::Length.new(6, "mm") + assert_equal "mm", thing.width_unit + end + + test "assigning a non-base unit to _unit converts it to its base unit" do + thing.width_unit = "millimetre" + assert_equal thing.width, Measured::Length.new(6, "mm") + assert_equal "mm", thing.width_unit + end + + test "building a new object from attributes builds a measured object" do + thing = Thing.new(length_value: "30", length_unit: "m") + assert_equal Measured::Length.new(30, :m), thing.length + end + + test "building a new object with a measured object assigns the properties" do + thing = Thing.new(length: new_length) + assert_equal new_length, thing.length + assert_equal 20, thing.length_value + assert_equal "in", thing.length_unit + end + + test "assigning attributes updates the measured object" do + thing.attributes = {length_value: "30", length_unit: "m"} + assert_equal Measured::Length.new(30, :m), thing.length + end + + test "assigning partial attributes updates the measured object" do + thing.attributes = {length_value: "30"} + assert_equal Measured::Length.new(30, :cm), thing.length + end + + test "assigning the _unit leaves the _value unchanged" do + thing.total_weight_unit = :lb + assert_equal thing.total_weight, Measured::Weight.new(200, "lb") + end + + test "assigning the _value leaves the _unit unchanged" do + thing.total_weight_value = "10" + assert_equal thing.total_weight, Measured::Weight.new(10, :g) + end + + test "assigning the _unit to an invalid unit does not raise" do + thing.total_weight_value = 123 + thing.total_weight_unit = :invalid + assert_nil thing.total_weight + end + + test "save persists the attributes and retrieves an object" do + thing = Thing.new length: Measured::Length.new(3, :m) + assert thing.save + assert_equal 3, thing.length_value + assert_equal "m", thing.length_unit + thing.reload + assert_equal 3, thing.length_value + assert_equal "m", thing.length_unit + end + + test "save pulls attributes from assigned object" do + thing = Thing.new total_weight_value: "100", total_weight_unit: :lb + assert thing.save + thing.reload + assert_equal 100, thing.total_weight_value + assert_equal "lb", thing.total_weight_unit + assert_equal Measured::Weight.new(100, :lb), thing.total_weight + end + + test "save succeeds if you assign an invalid unit and there is no validation" do + thing = Thing.new total_weight_value: "100", total_weight_unit: :invalid + assert thing.save + thing.reload + assert_nil thing.total_weight + assert_equal 100, thing.total_weight_value + end + + test "save fails if you assign an invalid unit and there is validation" do + thing = validated_thing + thing.length_unit = "invalid" + refute thing.save + assert_nil thing.length + end + + test "update sets one then the other" do + thing = Thing.create! + assert thing.update(width_value: 11.1) + assert_nil thing.width + assert thing.update(width_unit: "cm") + assert_equal Measured::Length.new(11.1, :cm), thing.width + end + + test "update sets only the _value column" do + thing = Thing.create! + assert thing.update(width_value: "314") + assert_equal 314, thing.width_value + thing.reload + assert_equal 314, thing.width_value + assert_nil thing.width + end + + test "update sets only the _unit column" do + thing = Thing.create! + assert thing.update(width_unit: :cm) + assert_equal "cm", thing.width_unit + thing.reload + assert_equal "cm", thing.width_unit + assert_nil thing.width + end + + test "update sets only the _unit column and converts it" do + thing = Thing.create! + assert thing.update(width_unit: "inch") + assert_equal "in", thing.width_unit + thing.reload + assert_equal "in", thing.width_unit + end + + test "update sets the _unit column to something invalid" do + thing = Thing.create! + assert thing.update(width_unit: :invalid) + assert_equal "invalid", thing.width_unit + thing.reload + assert_equal "invalid", thing.width_unit + assert_nil thing.width + end + + test "update does not set the _unit column to something invalid if there is validation" do + thing = validated_thing + thing.save! + refute thing.update(length_unit: :invalid) + end + + test "update sets one column then the other" do + thing = Thing.create! + assert thing.update(width_unit: "inch") + assert_nil thing.width + assert thing.update(width_value: "314") + assert_equal Measured::Length.new(314, :in), thing.width + end + + test "update sets both columns" do + thing = Thing.create! + assert thing.update(width_unit: :cm, width_value: 2) + assert_equal Measured::Length.new(2, :cm), thing.width + thing.reload + assert_equal Measured::Length.new(2, :cm), thing.width + end + + test "update modifies the _value column" do + assert thing.update(height_value: 2) + assert_equal Measured::Length.new(2, :m), thing.height + thing.reload + assert_equal Measured::Length.new(2, :m), thing.height + end + + test "update modifies only the _unit column" do + assert thing.update(height_unit: "foot") + assert_equal Measured::Length.new(1, :ft), thing.height + thing.reload + assert_equal Measured::Length.new(1, :ft), thing.height + end + + test "update modifies the _unit column to be something invalid" do + assert thing.update(height_unit: :invalid) + assert_nil thing.height + assert_equal "invalid", thing.height_unit + thing.reload + assert_nil thing.height + assert_equal "invalid", thing.height_unit + end + + test "update modifies both columns" do + assert thing.update(height_unit: "mm", height_value: 1.23) + assert_equal Measured::Length.new(1.23, :mm), thing.height + thing.reload + assert_equal Measured::Length.new(1.23, :mm), thing.height + end + + test "assigning the _value with a BigDecimal rounds to the column's rounding scale" do + thing.height = Measured::Length.new(BigDecimal('23.4567891'), :mm) + assert_equal thing.height_value, BigDecimal('23.46') + end + + test "assigning the _value with a float uses all the rounding scale permissible" do + thing.height = Measured::Length.new(4.45678912, :mm) + assert_equal thing.height_value, BigDecimal('4.46') + end + + test "assigning a number with more significant digits than permitted by the column precision does not raise exception when it can be rounded to have lesser significant digits per column's scale" do + assert_nothing_raised do + thing.height = Measured::Length.new(4.45678912123123123, :mm) + assert_equal thing.height_value, BigDecimal('4.46') + end + end + + test "assigning a number with more significant digits than permitted by the column precision raises exception" do + assert_raises Measured::Rails::Error, "The value 44567891212312312.3 being set for column: 'height' has too many significant digits. Please ensure it has no more than 10 significant digits." do + thing.height = Measured::Length.new(44567891212312312.3, :mm) + end + end + + test "assigning a large number but with a small amount of significant digits than permitted by the column precision raises exception" do + assert_raises Measured::Rails::Error, "The value 2000000000000000.0 being set for column: 'height' has too many significant digits. Please ensure it has no more than 10 significant digits." do + thing.height = Measured::Length.new(2_000_000_000_000_000, :mm) + end + end + + test "assigning a large number that's just smaller, equal to, and over the size of the column precision raises exception" do + assert_nothing_raised do + thing.height = Measured::Length.new(99999999.99, :mm) + end + + assert_raises Measured::Rails::Error, "The value 100000000.0 being set for column: 'height' has too many significant digits. Please ensure it has no more than 10 significant digits." do + thing.height = Measured::Length.new(100000000, :mm) + end + + assert_raises Measured::Rails::Error, "The value 100000000.01 being set for column: 'height' has too many significant digits. Please ensure it has no more than 10 significant digits." do + thing.height = Measured::Length.new(100000000.01, :mm) + end + end + + test "assigning a large number to a field that specifies max_on_assignment" do + thing = Thing.create!(length_with_max_on_assignment: Measured::Length.new(10000000000000000, :mm)) + assert_equal Measured::Length.new(500, :mm), thing.length_with_max_on_assignment + end + + test "assigning a small number to a field that specifies max_on_assignment" do + thing = Thing.create!(length_with_max_on_assignment: Measured::Length.new(1, :mm)) + assert_equal Measured::Length.new(1, :mm), thing.length_with_max_on_assignment + end + + test "using a similar unit accessor for multiple size fields" do + assert_equal Measured::Length.new(1, :m), custom_unit_thing.length + assert_equal Measured::Length.new(2, :m), custom_unit_thing.width + assert_equal Measured::Length.new(3, :m), custom_unit_thing.height + assert_equal Measured::Volume.new(9, :l), custom_unit_thing.volume + assert_equal Measured::Weight.new(10, :g), custom_unit_thing.total_weight + assert_equal Measured::Weight.new(12, :g), custom_unit_thing.extra_weight + end + + test "changing unit value when shared affects all fields" do + custom_unit_thing.length = Measured::Length.new(15, :in) + custom_unit_thing.total_weight = Measured::Weight.new(42, :kg) + + assert_equal custom_unit_thing.length, Measured::Length.new(15, :in) + assert_equal custom_unit_thing.width, Measured::Length.new(2, :in) + assert_equal custom_unit_thing.height, Measured::Length.new(3, :in) + assert_equal custom_unit_thing.total_weight, Measured::Weight.new(42, :kg) + assert_equal custom_unit_thing.extra_weight, Measured::Weight.new(12, :kg) + end + + test "using custom value fields works correctly" do + assert_equal custom_value_thing.length, Measured::Length.new(4, :m) + assert_equal custom_value_thing.width, Measured::Length.new(5, :m) + assert_equal custom_value_thing.height, Measured::Length.new(6, :m) + assert_equal custom_value_thing.volume, Measured::Volume.new(13, :l) + assert_equal custom_value_thing.total_weight, Measured::Weight.new(14, :g) + assert_equal custom_value_thing.extra_weight, Measured::Weight.new(15, :g) + end + + private + + def length + @length ||= Measured::Length.new(10, :cm) + end + + def new_length + @new_length ||= Measured::Length.new(20, :in) + end + + def thing + @thing ||= Thing.create!( + length: length, + width: Measured::Length.new(6, :in), + volume: Measured::Volume.new(6, :l), + height: Measured::Length.new(1, :m), + total_weight: Measured::Weight.new(200, :g), + extra_weight: Measured::Weight.new(16, :oz) + ) + end + + def validated_thing + @thing ||= ValidatedThing.new( + length: Measured::Length.new(1, :m), + length_true: Measured::Length.new(2, :cm), + length_message: Measured::Length.new(3, :mm), + length_message_from_block: Measured::Length.new(7, :mm), + length_units: Measured::Length.new(4, :m), + length_units_singular: Measured::Length.new(5, :ft), + length_presence: Measured::Length.new(6, :m), + length_numericality_inclusive: Measured::Length.new(15, :in), + length_numericality_exclusive: Measured::Length.new(4, :m), + length_numericality_equality: Measured::Length.new(100, :cm), + ) + end + + def custom_unit_thing + @custom_unit_thing ||= ThingWithCustomUnitAccessor.new( + length: Measured::Length.new(1, :m), + width: Measured::Length.new(2, :m), + height: Measured::Length.new(3, :m), + volume: Measured::Volume.new(9, :l), + total_weight: Measured::Weight.new(10, :g), + extra_weight: Measured::Weight.new(12, :g), + ) + end + + def custom_value_thing + @custom_value_thing ||= ThingWithCustomValueAccessor.new( + length: Measured::Length.new(4, :m), + width: Measured::Length.new(5, :m), + height: Measured::Length.new(6, :m), + volume: Measured::Volume.new(13, :l), + total_weight: Measured::Weight.new(14, :g), + extra_weight: Measured::Weight.new(15, :g), + ) + end +end diff --git a/test/rails/validation_test.rb b/test/rails/validation_test.rb new file mode 100644 index 0000000..afc69c6 --- /dev/null +++ b/test/rails/validation_test.rb @@ -0,0 +1,252 @@ +# frozen_string_literal: true +require "test_helper" + +class Measured::Rails::ValidationTest < ActiveSupport::TestCase + setup do + reset_db + end + + test "validation mock is valid" do + assert thing.valid? + end + + test "validation measurable: validation leaves a model valid and deals with blank unit" do + assert ValidatedThing.new(length_presence: Measured::Length.new(4, :in)).valid? + end + + test "validation fails when unit is nil" do + thing.length_unit = '' + refute thing.valid? + assert_equal({ length: ["is not a valid unit"] }, thing.errors.to_hash) + end + + test "validation fails on model with custom unit with nil value" do + custom_unit_thing.size_unit = '' + refute custom_unit_thing.valid? + assert_equal( + { + length: ["is not a valid unit"], + width: ["is not a valid unit"], + height: ["is not a valid unit"], + }, + custom_unit_thing.errors.to_hash + ) + end + + test "validation true works by default" do + thing.length_unit = "junk" + refute thing.valid? + assert_equal ["Length is not a valid unit"], thing.errors.full_messages + end + + test "validation can override the message with a static string" do + thing.length_message_unit = "junk" + refute thing.valid? + assert_equal ["Length message has a custom failure message"], thing.errors.full_messages + end + + test "validation can override the message with a block" do + thing.length_message_from_block_unit = "junk" + refute thing.valid? + assert_equal ["Length message from block junk is not a valid unit"], thing.errors.full_messages + end + + test "validation may be any valid unit" do + length_units.each do |unit| + thing.length_unit = unit + assert thing.valid? + thing.length_unit = unit.to_s + assert thing.valid? + thing.length = Measured::Length.new(123, unit) + assert thing.valid? + end + end + + test "validation accepts a list of units in any format as an option and only allows them to be valid" do + thing.length_units_unit = :m + assert thing.valid? + thing.length_units_unit = :cm + assert thing.valid? + thing.length_units_unit = "cm" + assert thing.valid? + thing.length_units_unit = "meter" + assert thing.valid? + thing.length_units = Measured::Length.new(3, :cm) + assert thing.valid? + thing.length_units_unit = :mm + refute thing.valid? + thing.length_units = Measured::Length.new(3, :ft) + refute thing.valid? + end + + test "validation lets the unit be singular" do + thing.length_units_singular_unit = :ft + assert thing.valid? + thing.length_units_singular_unit = "feet" + assert thing.valid? + thing.length_units_singular_unit = :mm + refute thing.valid? + thing.length_units_singular_unit = "meter" + refute thing.valid? + end + + test "validation for unit reasons uses the default message" do + thing.length_units_unit = :mm + refute thing.valid? + assert_equal ["Length units is not a valid unit"], thing.errors.full_messages + end + + test "validation for unit reasons also uses the custom message" do + thing.length_units_singular_unit = :mm + refute thing.valid? + assert_equal ["Length units singular custom message too"], thing.errors.full_messages + end + + test "validation for unit reasons adds one message if unit is not supported by default and is not custom supported" do + thing.length_units_singular_unit = :t + refute thing.valid? + + assert_equal ["Length units singular custom message too"], thing.errors.full_messages + end + + test "validation presence works on measured columns" do + thing.length_presence = nil + refute thing.valid? + assert_equal ["Length presence can't be blank"], thing.errors.full_messages + thing.length_presence_unit = "m" + refute thing.valid? + thing.length_presence_value = "3" + assert thing.valid? + end + + test "validation fails if only only the value is set" do + thing.length_unit = nil + refute thing.valid? + end + + test "validation checks that numericality comparisons are against a Measurable subclass" do + thing.length_invalid_comparison = Measured::Length.new(30, :in) + assert_raises ArgumentError, ":not_a_measured_subclass must be a Measurable object" do + thing.valid? + end + end + + test "validation for numericality uses a default invalid message" do + thing.length_numericality_inclusive = Measured::Length.new(30, :in) + refute thing.valid? + assert_equal ["Length numericality inclusive 30 in must be <= 20 in"], thing.errors.full_messages + + thing.length_numericality_inclusive = Measured::Length.new(1, :mm) + refute thing.valid? + assert_equal ["Length numericality inclusive 1 mm must be >= 10 in"], thing.errors.full_messages + end + + test "validation for numericality uses the override message" do + thing.length_numericality_exclusive = Measured::Length.new(2, :m) + refute thing.valid? + assert_equal ["Length numericality exclusive is super not ok"], thing.errors.full_messages + + thing.length_numericality_exclusive = Measured::Length.new(6000, :mm) + refute thing.valid? + assert_equal ["Length numericality exclusive is super not ok"], thing.errors.full_messages + end + + test "validation for numericality checks :greater_than and :less_than and can use symbols as method names to look up values" do + thing.length_numericality_exclusive = Measured::Length.new(4, :m) + assert thing.valid? + + thing.length_numericality_exclusive = Measured::Length.new(1, :m) + refute thing.valid? + end + + test "validation for numericality checks :greater_than_or_equal_to and :less_than_or_equal_to" do + thing.length_numericality_inclusive = Measured::Length.new(10, :in) + assert thing.valid? + + thing.length_numericality_exclusive = Measured::Length.new(3, :m) + refute thing.valid? + end + + test "validation for numericality checks :equal_to and can use procs to look up values" do + thing.length_numericality_equality = Measured::Length.new(100, :cm) + assert thing.valid? + + thing.length_numericality_equality = Measured::Length.new(1, :m) + assert thing.valid? + + thing.length_numericality_equality = Measured::Length.new("99.9", :cm) + refute thing.valid? + + thing.length_numericality_equality = Measured::Length.new(101, :cm) + refute thing.valid? + end + + test "validation for numericality handles a nil unit but a valid value" do + thing.length_numericality_exclusive_unit = nil + thing.length_numericality_exclusive_value = 1 + refute thing.valid? + end + + test "allow a nil value but a valid unit" do + thing.length_numericality_exclusive_unit = :cm + thing.length_numericality_exclusive_value = nil + assert thing.valid? + end + + test "validations work as expected given a measured field with custom validators" do + assert custom_unit_thing.valid? + + assert custom_unit_thing.size_unit = 'invalid' + + refute custom_unit_thing.valid? + + assert_equal(custom_unit_thing.errors[:length], ["is not a valid unit"]) + assert_equal(custom_unit_thing.errors[:height], ["is not a valid unit"]) + assert_equal(custom_unit_thing.errors[:width], ["is not a valid unit"]) + end + + test "validations work as expected on measured field with custom value accessor" do + assert custom_value_thing.valid? + end + + private + + def thing + @thing ||= ValidatedThing.new( + length: Measured::Length.new(1, :m), + length_true: Measured::Length.new(2, :cm), + length_message: Measured::Length.new(3, :mm), + length_message_from_block: Measured::Length.new(7, :mm), + length_units: Measured::Length.new(4, :m), + length_units_singular: Measured::Length.new(5, :ft), + length_presence: Measured::Length.new(6, :m), + length_numericality_inclusive: Measured::Length.new(15, :in), + length_numericality_exclusive: Measured::Length.new(4, :m), + length_numericality_equality: Measured::Length.new(100, :cm), + ) + end + + def custom_unit_thing + @custom_unit_thing ||= ThingWithCustomUnitAccessor.new( + length: Measured::Length.new(1, :m), + width: Measured::Length.new(2, :m), + height: Measured::Length.new(3, :m), + total_weight: Measured::Weight.new(10, :g), + extra_weight: Measured::Weight.new(12, :g), + ) + end + + def custom_value_thing + @custom_value_thing = ThingWithCustomValueAccessor.new( + length: Measured::Length.new(1, :m), + width: Measured::Length.new(2, :m), + height: Measured::Length.new(3, :m), + total_weight: Measured::Weight.new(10, :g), + extra_weight: Measured::Weight.new(12, :g), + ) + end + + def length_units + @length_units ||= [:m, :meter, :cm, :mm, :millimeter, :in, :ft, :feet, :yd] + end +end diff --git a/test/tapioca/dsl/compilers/measured_rails_test.rb b/test/tapioca/dsl/compilers/measured_rails_test.rb new file mode 100644 index 0000000..b4ad137 --- /dev/null +++ b/test/tapioca/dsl/compilers/measured_rails_test.rb @@ -0,0 +1,220 @@ +# typed: true +# frozen_string_literal: true + +require "test_helper" +require "tapioca/internal" +require "tapioca/helpers/test/dsl_compiler" +require "tapioca/dsl/compilers/measured_rails" + +module Tapioca + module Dsl + module Compilers + class MeasuredRailsTest < ActiveSupport::TestCase + include Tapioca::Helpers::Test::DslCompiler + + setup do + use_dsl_compiler(Tapioca::Dsl::Compilers::MeasuredRails) + end + + test "#initialize gathers only ActiveRecord subclasses" do + add_ruby_file("content.rb", <<~RUBY) + class Post < ActiveRecord::Base + end + class Current + end + RUBY + + assert_includes(gathered_constants, "Post") + refute_includes(gathered_constants, "Current") + end + + test "generates empty RBI file if there are no measured fields" do + add_ruby_file("package.rb", <<~RUBY) + class Package < ActiveRecord::Base + end + RUBY + + expected = <<~RBI + # typed: strong + RBI + + assert_equal(expected, rbi_for(:Package)) + end + + test "generates RBI file for measured method" do + add_ruby_file("schema.rb", <<~RUBY) + ActiveRecord::Migration.suppress_messages do + ActiveRecord::Schema.define do + create_table :packages, force: :cascade do |t| + t.decimal :minimum_weight_value, precision: 10, scale: 2 + t.string :minimum_weight_unit, limit: 12 + t.decimal :total_length_value, precision: 10, scale: 2, default: 0 + t.string :total_length_unit, limit: 12, default: "cm" + t.decimal :total_volume_value, precision: 10, scale: 2, default: 0 + t.string :total_volume_unit, limit: 12, default: "l" + end + end + end + RUBY + + add_ruby_file("package.rb", <<~RUBY) + class Package < ActiveRecord::Base + measured Measured::Weight, :minimum_weight + measured Measured::Length, :total_length + measured Measured::Volume, :total_volume + end + RUBY + + expected = <<~RBI + # typed: strong + + class Package + include GeneratedMeasuredRailsMethods + + module GeneratedMeasuredRailsMethods + sig { returns(T.nilable(Measured::Weight)) } + def minimum_weight; end + + sig { params(value: T.nilable(Measured::Weight)).void } + def minimum_weight=(value); end + + sig { returns(T.nilable(Measured::Length)) } + def total_length; end + + sig { params(value: T.nilable(Measured::Length)).void } + def total_length=(value); end + + sig { returns(T.nilable(Measured::Volume)) } + def total_volume; end + + sig { params(value: T.nilable(Measured::Volume)).void } + def total_volume=(value); end + end + end + RBI + + assert_equal(expected, rbi_for(:Package)) + end + + test "generates RBI file for measured_weight method" do + add_ruby_file("schema.rb", <<~RUBY) + ActiveRecord::Migration.suppress_messages do + ActiveRecord::Schema.define do + create_table :packages, force: :cascade do |t| + t.decimal :minimum_weight_value, precision: 10, scale: 2 + t.string :minimum_weight_unit, limit: 12 + t.decimal :total_length_value, precision: 10, scale: 2, default: 0 + t.string :total_length_unit, limit: 12, default: "cm" + t.decimal :total_volume_value, precision: 10, scale: 2, default: 0 + t.string :total_volume_unit, limit: 12, default: "l" + end + end + end + RUBY + + add_ruby_file("package.rb", <<~RUBY) + class Package < ActiveRecord::Base + measured_weight :minimum_weight + end + RUBY + + expected = <<~RBI + # typed: strong + + class Package + include GeneratedMeasuredRailsMethods + + module GeneratedMeasuredRailsMethods + sig { returns(T.nilable(Measured::Weight)) } + def minimum_weight; end + + sig { params(value: T.nilable(Measured::Weight)).void } + def minimum_weight=(value); end + end + end + RBI + + assert_equal(expected, rbi_for(:Package)) + end + + test "generates RBI file for measured_length method" do + add_ruby_file("schema.rb", <<~RUBY) + ActiveRecord::Migration.suppress_messages do + ActiveRecord::Schema.define do + create_table :packages, force: :cascade do |t| + t.decimal :minimum_weight_value, precision: 10, scale: 2 + t.string :minimum_weight_unit, limit: 12 + t.decimal :total_length_value, precision: 10, scale: 2, default: 0 + t.string :total_length_unit, limit: 12, default: "cm" + t.decimal :total_volume_value, precision: 10, scale: 2, default: 0 + t.string :total_volume_unit, limit: 12, default: "l" + end + end + end + RUBY + + add_ruby_file("package.rb", <<~RUBY) + class Package < ActiveRecord::Base + measured_length :total_length + end + RUBY + + expected = <<~RBI + # typed: strong + + class Package + include GeneratedMeasuredRailsMethods + + module GeneratedMeasuredRailsMethods + sig { returns(T.nilable(Measured::Length)) } + def total_length; end + + sig { params(value: T.nilable(Measured::Length)).void } + def total_length=(value); end + end + end + RBI + + assert_equal(expected, rbi_for(:Package)) + end + + test "generates RBI file for measured_volume method" do + add_ruby_file("schema.rb", <<~RUBY) + ActiveRecord::Migration.suppress_messages do + ActiveRecord::Schema.define do + create_table :packages, force: :cascade do |t| + t.decimal :total_volume_value, precision: 10, scale: 2, default: 0 + t.string :total_volume_unit, limit: 12, default: "l" + end + end + end + RUBY + + add_ruby_file("package.rb", <<~RUBY) + class Package < ActiveRecord::Base + measured_volume :total_volume + end + RUBY + + expected = <<~RBI + # typed: strong + + class Package + include GeneratedMeasuredRailsMethods + + module GeneratedMeasuredRailsMethods + sig { returns(T.nilable(Measured::Volume)) } + def total_volume; end + + sig { params(value: T.nilable(Measured::Volume)).void } + def total_volume=(value); end + end + end + RBI + + assert_equal(expected, rbi_for(:Package)) + end + end + end + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 7cf3da8..3dc727f 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,5 +1,9 @@ # frozen_string_literal: true + require "pry" unless ENV["CI"] +require "combustion" +Combustion.path = "test/internal" +Combustion.initialize! :active_record, :active_model require "measured" require "minitest/reporters" require "minitest/autorun" @@ -7,6 +11,13 @@ ActiveSupport.test_order = :random +# Prevent two reporters from printing +# https://github.com/kern/minitest-reporters/issues/230 +# https://github.com/rails/rails/issues/30491 +Minitest.load_plugins +Minitest.extensions.delete('rails') +Minitest.extensions.unshift('rails') + Minitest::Reporters.use! [Minitest::Reporters::ProgressReporter.new(color: true)] require "support/subclasses" @@ -44,4 +55,8 @@ def assert_raises_with_message(exception, expected_message) error = assert_raise(exception) { yield } assert_equal expected_message, error.message, "Exception #{exception} raised but messages are not equal" end + + def reset_db + Combustion::Database::LoadSchema.call + end end From 448bd1bed285c8a75d3591ed65317ba8153fdde0 Mon Sep 17 00:00:00 2001 From: Ufuk Kayserilioglu Date: Tue, 16 Apr 2024 00:17:14 +0300 Subject: [PATCH 2/2] Add Active Record adapter documentation --- README.md | 116 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 113 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index fb8f41a..33f2e20 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. ### Gem: [quantified](https://github.com/Shopify/quantified) * **Pros** @@ -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** @@ -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