From c477d95604e80e04d1aa8113a8236353235eb86a Mon Sep 17 00:00:00 2001 From: Petrik Date: Wed, 21 Apr 2021 20:54:16 +0200 Subject: [PATCH] Introduce ActiveModel::API Currently `ActiveModel::Model` is defined as the minimum API to talk with Action Pack and Action View. Its name suggests it can be included to create Active Record type models, but for creating models it's probably too minimal. For example it's very common to include ActiveModel::Attributes as well. By moving `ActiveModel::Model`'s implementation to a new `ActiveModel::API` we keep a definition of the minimum API to talk with Action Pack and Action View. For `ActiveModel::Model` we only need to include `ActiveModel::API`. This will allow adding more funcationality to `ActiveModel::Model` while keeping backwards compatibility. Co-authored-by: Nathaniel Watts <1141717+thewatts@users.noreply.github.com> --- activemodel/CHANGELOG.md | 7 ++ activemodel/README.rdoc | 6 +- activemodel/lib/active_model.rb | 1 + activemodel/lib/active_model/api.rb | 99 +++++++++++++++++++++++++++ activemodel/lib/active_model/model.rb | 65 ++---------------- activemodel/test/cases/api_test.rb | 79 +++++++++++++++++++++ guides/source/active_model_basics.md | 75 ++++++++++++-------- 7 files changed, 243 insertions(+), 89 deletions(-) create mode 100644 activemodel/lib/active_model/api.rb create mode 100644 activemodel/test/cases/api_test.rb diff --git a/activemodel/CHANGELOG.md b/activemodel/CHANGELOG.md index d7513dce8a..3cb290fdd0 100644 --- a/activemodel/CHANGELOG.md +++ b/activemodel/CHANGELOG.md @@ -1,3 +1,10 @@ +* Introduce `ActiveModel::API`. + + Make `ActiveModel::API` the minimum API to talk with Action Pack and Action View. + This will allow adding more functionality to `ActiveModel::Model`. + + *Petrik de Heus*, *Nathaniel Watts* + * Fix dirty check for Float::NaN and BigDecimal::NaN. Float::NaN and BigDecimal::NaN in Ruby are [special values](https://bugs.ruby-lang.org/issues/1720) diff --git a/activemodel/README.rdoc b/activemodel/README.rdoc index f68c5bfcd0..12e3a927f1 100644 --- a/activemodel/README.rdoc +++ b/activemodel/README.rdoc @@ -16,10 +16,10 @@ Model solves this by defining an explicit API. You can read more about the API in ActiveModel::Lint::Tests. Active Model provides a default module that implements the basic API required -to integrate with Action Pack out of the box: ActiveModel::Model. +to integrate with Action Pack out of the box: ActiveModel::API. class Person - include ActiveModel::Model + include ActiveModel::API attr_accessor :name, :age validates_presence_of :name @@ -32,7 +32,7 @@ to integrate with Action Pack out of the box: ActiveModel::Model. It includes model name introspections, conversions, translations and validations, resulting in a class suitable to be used with Action Pack. -See ActiveModel::Model for more examples. +See ActiveModel::API for more examples. Active Model also provides the following functionality to have ORM-like behavior out of the box: diff --git a/activemodel/lib/active_model.rb b/activemodel/lib/active_model.rb index c93c248591..8af205bf59 100644 --- a/activemodel/lib/active_model.rb +++ b/activemodel/lib/active_model.rb @@ -30,6 +30,7 @@ require "active_model/version" module ActiveModel extend ActiveSupport::Autoload + autoload :API autoload :Attribute autoload :Attributes autoload :AttributeAssignment diff --git a/activemodel/lib/active_model/api.rb b/activemodel/lib/active_model/api.rb new file mode 100644 index 0000000000..b058526f54 --- /dev/null +++ b/activemodel/lib/active_model/api.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +module ActiveModel + # == Active \Model \API + # + # Includes the required interface for an object to interact with + # Action Pack and Action View, using different Active Model modules. + # It includes model name introspections, conversions, translations and + # validations. Besides that, it allows you to initialize the object with a + # hash of attributes, pretty much like Active Record does. + # + # A minimal implementation could be: + # + # class Person + # include ActiveModel::API + # attr_accessor :name, :age + # end + # + # person = Person.new(name: 'bob', age: '18') + # person.name # => "bob" + # person.age # => "18" + # + # Note that, by default, ActiveModel::API implements persisted? + # to return +false+, which is the most common case. You may want to override + # it in your class to simulate a different scenario: + # + # class Person + # include ActiveModel::API + # attr_accessor :id, :name + # + # def persisted? + # self.id.present? + # end + # end + # + # person = Person.new(id: 1, name: 'bob') + # person.persisted? # => true + # + # Also, if for some reason you need to run code on initialize, make + # sure you call +super+ if you want the attributes hash initialization to + # happen. + # + # class Person + # include ActiveModel::API + # attr_accessor :id, :name, :omg + # + # def initialize(attributes={}) + # super + # @omg ||= true + # end + # end + # + # person = Person.new(id: 1, name: 'bob') + # person.omg # => true + # + # For more detailed information on other functionalities available, please + # refer to the specific modules included in ActiveModel::API + # (see below). + module API + extend ActiveSupport::Concern + include ActiveModel::AttributeAssignment + include ActiveModel::Validations + include ActiveModel::Conversion + + included do + extend ActiveModel::Naming + extend ActiveModel::Translation + end + + # Initializes a new model with the given +params+. + # + # class Person + # include ActiveModel::API + # attr_accessor :name, :age + # end + # + # person = Person.new(name: 'bob', age: '18') + # person.name # => "bob" + # person.age # => "18" + def initialize(attributes = {}) + assign_attributes(attributes) if attributes + + super() + end + + # Indicates if the model is persisted. Default is +false+. + # + # class Person + # include ActiveModel::API + # attr_accessor :id, :name + # end + # + # person = Person.new(id: 1, name: 'bob') + # person.persisted? # => false + def persisted? + false + end + end +end diff --git a/activemodel/lib/active_model/model.rb b/activemodel/lib/active_model/model.rb index fc52cd4fdf..2caac82e52 100644 --- a/activemodel/lib/active_model/model.rb +++ b/activemodel/lib/active_model/model.rb @@ -3,11 +3,10 @@ module ActiveModel # == Active \Model \Basic \Model # - # Includes the required interface for an object to interact with - # Action Pack and Action View, using different Active Model modules. - # It includes model name introspections, conversions, translations and - # validations. Besides that, it allows you to initialize the object with a - # hash of attributes, pretty much like Active Record does. + # Allows implementing models similar to ActiveRecord::Base. + # Includes ActiveModel::API for the required interface for an + # object to interact with Action Pack and Action View, but can be + # extended with other functionalities. # # A minimal implementation could be: # @@ -20,23 +19,7 @@ module ActiveModel # person.name # => "bob" # person.age # => "18" # - # Note that, by default, ActiveModel::Model implements persisted? - # to return +false+, which is the most common case. You may want to override - # it in your class to simulate a different scenario: - # - # class Person - # include ActiveModel::Model - # attr_accessor :id, :name - # - # def persisted? - # self.id == 1 - # end - # end - # - # person = Person.new(id: 1, name: 'bob') - # person.persisted? # => true - # - # Also, if for some reason you need to run code on initialize, make + # If for some reason you need to run code on initialize, make # sure you call +super+ if you want the attributes hash initialization to # happen. # @@ -58,42 +41,6 @@ module ActiveModel # (see below). module Model extend ActiveSupport::Concern - include ActiveModel::AttributeAssignment - include ActiveModel::Validations - include ActiveModel::Conversion - - included do - extend ActiveModel::Naming - extend ActiveModel::Translation - end - - # Initializes a new model with the given +params+. - # - # class Person - # include ActiveModel::Model - # attr_accessor :name, :age - # end - # - # person = Person.new(name: 'bob', age: '18') - # person.name # => "bob" - # person.age # => "18" - def initialize(attributes = {}) - assign_attributes(attributes) if attributes - - super() - end - - # Indicates if the model is persisted. Default is +false+. - # - # class Person - # include ActiveModel::Model - # attr_accessor :id, :name - # end - # - # person = Person.new(id: 1, name: 'bob') - # person.persisted? # => false - def persisted? - false - end + include ActiveModel::API end end diff --git a/activemodel/test/cases/api_test.rb b/activemodel/test/cases/api_test.rb new file mode 100644 index 0000000000..5c0cc7fb37 --- /dev/null +++ b/activemodel/test/cases/api_test.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require "cases/helper" + +class APITest < ActiveModel::TestCase + include ActiveModel::Lint::Tests + + module DefaultValue + def self.included(klass) + klass.class_eval { attr_accessor :hello } + end + + def initialize(*args) + @attr ||= "default value" + super + end + end + + class BasicModel + include DefaultValue + include ActiveModel::API + attr_accessor :attr + end + + class BasicModelWithReversedMixins + include ActiveModel::API + include DefaultValue + attr_accessor :attr + end + + class SimpleModel + include ActiveModel::API + attr_accessor :attr + end + + def setup + @model = BasicModel.new + end + + def test_initialize_with_params + object = BasicModel.new(attr: "value") + assert_equal "value", object.attr + end + + def test_initialize_with_params_and_mixins_reversed + object = BasicModelWithReversedMixins.new(attr: "value") + assert_equal "value", object.attr + end + + def test_initialize_with_nil_or_empty_hash_params_does_not_explode + assert_nothing_raised do + BasicModel.new() + BasicModel.new(nil) + BasicModel.new({}) + SimpleModel.new(attr: "value") + end + end + + def test_persisted_is_always_false + object = BasicModel.new(attr: "value") + assert object.persisted? == false + end + + def test_mixin_inclusion_chain + object = BasicModel.new + assert_equal "default value", object.attr + end + + def test_mixin_initializer_when_args_exist + object = BasicModel.new(hello: "world") + assert_equal "world", object.hello + end + + def test_mixin_initializer_when_args_dont_exist + assert_raises(ActiveModel::UnknownAttributeError) do + SimpleModel.new(hello: "world") + end + end +end diff --git a/guides/source/active_model_basics.md b/guides/source/active_model_basics.md index 05d87bfd82..2ca2c52405 100644 --- a/guides/source/active_model_basics.md +++ b/guides/source/active_model_basics.md @@ -24,6 +24,52 @@ Active Model is a library containing various modules used in developing classes that need some features present on Active Record. Some of these modules are explained below. +### API + +`ActiveModel::API` adds the ability for a class to work with Action Pack and +Action View right out of the box. + +```ruby +class EmailContact + include ActiveModel::API + + attr_accessor :name, :email, :message + validates :name, :email, :message, presence: true + + def deliver + if valid? + # deliver email + end + end +end +``` + +When including `ActiveModel::API` you get some features like: + +- model name introspection +- conversions +- translations +- validations + +It also gives you the ability to initialize an object with a hash of attributes, +much like any Active Record object. + +```irb +irb> email_contact = EmailContact.new(name: 'David', email: 'david@example.com', message: 'Hello World') +irb> email_contact.name +=> "David" +irb> email_contact.email +=> "david@example.com" +irb> email_contact.valid? +=> true +irb> email_contact.persisted? +=> false +``` + +Any class that includes `ActiveModel::API` can be used with `form_with`, +`render` and any other Action View helper methods, just like Active Record +objects. + ### Attribute Methods The `ActiveModel::AttributeMethods` module can add custom prefixes and suffixes @@ -276,8 +322,7 @@ Person.model_name.singular_route_key # => "person" ### Model -`ActiveModel::Model` adds the ability for a class to work with Action Pack and -Action View right out of the box. +`ActiveModel::Model` allows implementing models similar to `ActiveRecord::Base`. ```ruby class EmailContact @@ -294,31 +339,7 @@ class EmailContact end ``` -When including `ActiveModel::Model` you get some features like: - -- model name introspection -- conversions -- translations -- validations - -It also gives you the ability to initialize an object with a hash of attributes, -much like any Active Record object. - -```irb -irb> email_contact = EmailContact.new(name: 'David', email: 'david@example.com', message: 'Hello World') -irb> email_contact.name -=> "David" -irb> email_contact.email -=> "david@example.com" -irb> email_contact.valid? -=> true -irb> email_contact.persisted? -=> false -``` - -Any class that includes `ActiveModel::Model` can be used with `form_with`, -`render` and any other Action View helper methods, just like Active Record -objects. +When including `ActiveModel::Model` you get all the features from `ActiveModel::API`. ### Serialization