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>
This commit is contained in:
Petrik 2021-04-21 20:54:16 +02:00
parent f832fa58d3
commit c477d95604
7 changed files with 243 additions and 89 deletions

View File

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

View File

@ -16,10 +16,10 @@ Model solves this by defining an explicit API. You can read more about the
API in <tt>ActiveModel::Lint::Tests</tt>.
Active Model provides a default module that implements the basic API required
to integrate with Action Pack out of the box: <tt>ActiveModel::Model</tt>.
to integrate with Action Pack out of the box: <tt>ActiveModel::API</tt>.
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: <tt>ActiveModel::Model</tt>.
It includes model name introspections, conversions, translations and
validations, resulting in a class suitable to be used with Action Pack.
See <tt>ActiveModel::Model</tt> for more examples.
See <tt>ActiveModel::API</tt> for more examples.
Active Model also provides the following functionality to have ORM-like
behavior out of the box:

View File

@ -30,6 +30,7 @@ require "active_model/version"
module ActiveModel
extend ActiveSupport::Autoload
autoload :API
autoload :Attribute
autoload :Attributes
autoload :AttributeAssignment

View File

@ -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, <tt>ActiveModel::API</tt> implements <tt>persisted?</tt>
# 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 <tt>initialize</tt>, 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 <tt>ActiveModel::API</tt>
# (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

View File

@ -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 <tt>ActiveRecord::Base</tt>.
# Includes <tt>ActiveModel::API</tt> 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, <tt>ActiveModel::Model</tt> implements <tt>persisted?</tt>
# 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 <tt>initialize</tt>, make
# If for some reason you need to run code on <tt>initialize</tt>, 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

View File

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

View File

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