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_namesI 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
Stringinto 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
attributeto the type's constructor - it defines an accessor method for the attribute that invokes
Type#assert_valid_valuefollowed byType#castfor 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 # => trueThere 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.freezeSo 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::Typeregistry.
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#typehas to be eitherguestorsubscriber), - types for parsing incoming values (e.g.
JSONparses strings that are assigned to the attribute), - types for enforcing invariants (e.g. raise an
ArgumentErrorunless 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
endThis implements the minimal interface for a type:
assert_valid_valuethrows an error if the value is invalid,casttransforms 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')
endNote 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.runUsing 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)
endNow we can use it like this:
class User
include ActiveModel::Model
include ActiveModel::Attributes
attribute :type, :enum, of: %w[guest subscriber]
endLooks 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:
deserializewhich is responsible to cast the value to a Ruby value from the raw database value,serializewhich 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.rbPutting 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
endThe 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.