Rails' hidden type system
Table of contents
Recently I stumbled over the following code snippet in the Ruby on Rails API documentation:
class Person
include ActiveModel::Attributes
attribute :name, :string
attribute :age, :integer
end
Person.attribute_names
I went looking for how to define an ActiveModel
again and happened to find this.
What caught my attention were the type annotations: :string
and :integer
.
The attribute definitions looked a lot like what I knew from dry-types except that I didn't expect any of this in the Rails core.
Since the ActiveModel
modules are usually a good candidate for implementing domain model objects
that do not depend on persistence logic, this discovery was very exciting!
Finally a way to define domain objects as (mostly) POROs! Without an additional dependency in the project!
The excitement was followed by a deep dive into how these types work and what you can do with them. Documentation on the web was scarce, so here's an overview:
What's in a type?
The main purpose of the types as implemented by ActiveModel
is to run a bit of code to transform values
when they are assigned to an attribute.
On the surface this doesn't sound very exciting, however it offers a nice set of benefits:
- you can convert incoming values into domain objects encapsulating logic (e.g. turn a
String
into aDateTime
) - you have an overview of what attributes to expect on a domain object,
- you can easily enforce the validity of data coming into the system.
When you define a model like the Person
class above, ActiveModel
does the following:
- it uses the symbol naming the type, e.g.
:integer
, to look up the type definition in the type registry - it passes any remaining arguments used to call
attribute
to the type's constructor - it defines an accessor method for the attribute that invokes
Type#assert_valid_value
followed byType#cast
for the assigned value.
As it happens the constructor defined by ActiveModel::Model
also uses attribute accessor methods to assign all attributes
so you can be sure that your type is used when instantiating new objects
and when updating attributes on existing objects.
Usually #cast
would do one of two things: return any value or raise an exception.
Rails' builtin types
Rails ships with quite a few types that are preregistered.
The definition is somewhat hidden in active_model/type.rb:
register(:big_integer, Type::BigInteger)
register(:binary, Type::Binary)
register(:boolean, Type::Boolean)
register(:date, Type::Date)
register(:datetime, Type::DateTime)
register(:decimal, Type::Decimal)
register(:float, Type::Float)
register(:immutable_string, Type::ImmutableString)
register(:integer, Type::Integer)
register(:string, Type::String)
register(:time, Type::Time)
These types would cover most of the basic needs one has for types, except that they are all non-strict.
This means that they do their best to conform the incoming value to the desired type.
Here's how the boolean type works:
class FeatureFlag
include ActiveModel::Attributes
attribute :enabled, :boolean, default: false
end
puts FeatureFlag.new(enabled: 'off').enabled # => false
puts FeatureFlag.new(enabled: 'on').enabled # => true
There are a few string values that are interpreted as booleans,
confusingly yes
and no
are not among them:
# === Coercion
# Values set from user input will first be coerced into the appropriate ruby type.
# Coercion behavior is roughly mapped to Ruby's boolean semantics.
#
# - "false", "f" , "0", +0+ or any other value in +FALSE_VALUES+ will be coerced to +false+
# - Empty strings are coerced to +nil+
# - All other values will be coerced to +true+
class Boolean < Value
FALSE_VALUES = [
false, 0,
"0", :"0",
"f", :f,
"F", :F,
"false", :false,
"FALSE", :FALSE,
"off", :off,
"OFF", :OFF,
].to_set.freeze
So Rails ships with a lot of types, but the guarantees they provide are very weak. This begs the question then of how to define your own type?
Adding your own types
To implement your own type you need to do two things:
- define a class with a
#cast(value)
and#assert_valid_value(value)
instance method, - (optionally) register that class with the
ActiveModel::Type
registry.
Prime candidates for implementing your own types would be:
- types for domain-specific value objects (think
Money
,URI
,ISBN
,AuthToken
,Password
, etc) - types for collections (e.g.
User#type
has to be eitherguest
orsubscriber
), - types for parsing incoming values (e.g.
JSON
parses strings that are assigned to the attribute), - types for enforcing invariants (e.g. raise an
ArgumentError
unless a condition holds true)
Let's start with an interesting example: an :enum
type that makes sure
that the value belongs to a set of allowed values.
require 'set'
class EnumType
def initialize(*allowed_values)
@allowed_values = Set.new(allowed_values)
end
def cast(value)
value
end
def assert_valid_value(value)
return if @allowed_values.include?(value)
raise ArgumentError,
"#{value.inspect} not included in #{@allowed_values.inspect}"
end
end
This implements the minimal interface for a type:
assert_valid_value
throws an error if the value is invalid,cast
transforms the value if necessary.
In our case we don't want to transform the value if it is valid, but we do want to raise an error if the value is not one of the preapproved values.
Let's use this this in a model:
require 'active_model'
class User
include ActiveModel::Model
include ActiveModel::Attributes
attribute :type, EnumType.new('guest', 'subscriber')
end
Note that we include ActiveModel::Model
to define a constructor for us
that assigns all attributes from a hash.
With this in place we can now use User.new(params)
and be sure
that type
is always either guest
or subscriber
.
Here are the specs to prove this:
require 'minitest/spec'
class EnumTypeSpec < Minitest::Spec
describe 'when an invalid value is provided' do
it 'raises an ArgumentError in the constructor' do
value(proc { User.new(type: 'invalid') }).must_raise(ArgumentError)
end
it 'raises an ArgumentError when assigning the attribute' do
user = User.new(type: 'guest')
value(proc { user.type = 'invalid' }).must_raise(ArgumentError)
end
end
describe 'when a valid value is provided' do
it 'assigns the value' do
user = User.new(type: 'guest')
expect(user.type).must_equal('guest')
end
end
end
Minitest.run
Using this type still leaves a bit to be desired:
- it's inconsistent: other attribute types are defined via symbols, this one is not
- it cannot be discovered programmatically (all types are registered, except this one).
Luckily we don't need to do much to change this: we change our class definition to register the type and accept an keyword argument as an option:
class EnumType
def initialize(of:)
@allowed_values = Set.new(of)
end
# cast and assert_valid_value stay the same
ActiveModel::Type.register(:enum, self)
end
Now we can use it like this:
class User
include ActiveModel::Model
include ActiveModel::Attributes
attribute :type, :enum, of: %w[guest subscriber]
end
Looks just like the builtin types and our specs still pass!
Integrating with ActiveRecord
If you look at the documentation for the generic type ActiveModel::Type::Value
,
you see that there a quite a few more methods than what we implemented on our EnumType
.
This is because Rails assumes that your models will be ActiveRecord objects and thus will have to be persisted to a database and read from a database.
In order for a type to support this there is a pair of methods:
deserialize
which is responsible to cast the value to a Ruby value from the raw database value,serialize
which is responsible for converting to Ruby value to one of the basic types understood by a database.
If you are looking at ActiveModel, you are probably not implementing an object that's backed by a database.
Still it makes sense to keep these two methods in mind.
To make compatibility with ActiveRecord easier, it's a good idea to have your custom types
inherit from ActiveModel::Type::Value
.
This clearly communicates that you are defining a type and makes the migration to an ActiveRecord-compatible type easier when you need to.
Integrating with the Rails directory structure
Always knowing where code goes is one of Rails' strengths. In the case of custom attribute types there is no existing convention I'm aware of with regards to the placement of files.
I suggest:
+ app
|
`-+ types
| |
| `- enum_type.rb
| `- ..._type.rb
`-+ models
|
`- application_model.rb
Putting new types under app/types
works well with Rails' autoloading
and matches the conventions for other architectural components (controllers
, etc).
In a similar vein application_model.rb
can hold a base class for all your models,
just like ApplicationRecord
does for ActiveRecord
-based models:
class ApplicationModel
include ActiveModel::Model
include ActiveModel::Attributes
end
The bigger picture
Over the last few years concepts like types and schemas (be they static or at runtime), separating domain logic from persistence logic (e.g. ROM) and code that handles state transitions from the code that describes the current state of things (i.e. service/command/interaction objects) have gained more popularity.
This has led to the birth of many useful and interesting libraries and frameworks like the dry-rb family of gems and Hanami.
Rails builtin tooling can be leveraged for implementing these ideas without having to introduce a new dependency into your project or having to train your whole team on wholly new concepts like monads.
Just by using ActiveModel
based classes for your domain objects you:
- keep persistence logic out of your domain logic,
- don't fall victim to ActiveRecord callbacks,
- ensure that your domain logic is easy to test -- no need to stub out a database.
Additionally you can use the ActiveModel
based classes for modelling state transitions in your system,
like form- or parameter objects that are handled by your domain services or controllers.
By staying with Rails' builtin tooling you also reduce the likelihood of problems when it's time to upgrade to the next Rails version. Likewise you can carry your knowledge over to every other Rails project you are working on, since you are not relying on additional gems.