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