A bit of Active Record's magic 🪄
Have you ever wondered how ActiveRecord, the ORM behind Ruby on Rails, functions? ORMs are crucial tools that simplify database interactions in web applications, yet many developers use them without understanding their inner workings.
Why is that? Well, as long as it works and gets the job done there is no good reason to dive into such a complex piece of software, …
Even though I agree on that one, curiosity gets the best of us, and I always used to wonder: How does ActiveRecord manage to define getter/setter methods based on our DB Schema?
This might seem like a basic question, but it’s fundamental to how we use ActiveRecord daily. This “harmless” question is a good one to start understanding how ActiveRecord internals work, and how some of those Ruby metaprogramming features we talked about in the previous post come in handy here too 💪.
🏊♂️ into ActiveRecord::Base
You may have observed that models/application_record.rb
typically contains minimal code and inherits from the ActiveRecord::Base
class. Inside ActiveRecord::Base
you’ll see a lot of include
and extend
calls on different modules that play a role in how ActiveRecord works:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
module ActiveRecord
class Base
extend ActiveModel::Naming
extend ActiveSupport::Benchmarkable
extend ActiveSupport::DescendantsTracker
extend ConnectionHandling
extend QueryCache::ClassMethods
extend Querying
extend Translation
extend DynamicMatchers
extend DelegatedType
extend Explain
extend Enum
extend Delegation::DelegateCache
extend Aggregations::ClassMethods
include Core
include Persistence
include ReadonlyAttributes
include ModelSchema
include Inheritance
include Scoping
include Sanitization
include AttributeAssignment
include ActiveModel::Conversion
include Integration
include Validations
include CounterCache
include Attributes
include Locking::Optimistic
include Locking::Pessimistic
include Encryption::EncryptableRecord
include AttributeMethods
include Callbacks
include Timestamp
include Associations
include SecurePassword
include AutosaveAssociation
include NestedAttributes
include Transactions
include TouchLater
include NoTouching
include Reflection
include Serialization
include Store
include SecureToken
include TokenFor
include SignedId
include Suppressor
include Normalization
include Marshalling::Methods
#...
end
end
That’s a lot of modules 😱! We’ll only dive into a couple of them though; after skimming through the code, I found that the getter/setter method definitions based on our DB Schema take place in the ModelSchema
and AttributeMethods
modules.
AttributeMethods Module
Let’s first dive into the AttributeMethods
module and suppose we already know what the columns in our DB tables are. The first couple of things that caught my eye when digging into this module were the following:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
module ActiveRecord
module AttributeMethods
extend ActiveSupport::Concern
include ActiveModel::AttributeMethods
included do
initialize_generated_modules
include Read
include Write
include BeforeTypeCast
include Query
include PrimaryKey
include TimeZoneConversion
include Dirty
include Serialization
end
class GeneratedAttributeMethods < Module
include Mutex_m
end
#...
module ClassMethods
def initialize_generated_modules
@generated_attribute_methods = const_set(:GeneratedAttributeMethods, GeneratedAttributeMethods.new)
private_constant :GeneratedAttributeMethods
@attribute_methods_generated = false
@alias_attributes_mass_generated = false
include @generated_attribute_methods
super
end
#...
end
end
end
First of all, this module heavily relies on ActiveModel::AttributeMethods
, which gives us methods that you might be familiar with, such as: attribute_method_prefix
, alias_attribute
, and more.
It’s important to remember that the ActiveModel’s API was extracted from the ActiveRecord component to allow us to create POROs with “model-like” behavior (validations, type casting, etc.).
Then, you can see a bunch of sub-modules included that self-describe their purpose, such as Read
(for reader/getter methods), Write
(for writer/setter methods), PrimaryKey
, Dirty
(for tracking changes), etc.
And finally, you can see a method initialize_generated_modules
that gets called as soon as the module is included, where it sets up an empty GeneratedAttributeMethods
module into the @generated_attribute_methods
variable. Inside this GeneratedAttributeMethods
module is where all of the getter/setter methods will be defined, and then included in your class.
This pattern of defining an empty module, then dynamically adding methods to it and finally including it is a common approach you’ll see in most of Ruby on Rails codebase.
When reading any Ruby open-source code, looking at the “ClassMethods” module within the module you’re looking at might be helpful, since usually the “API” or “DSL” methods live in there.
But how does it work!?
Now that we know the basic structure, let’s dive into the actual code that defines the getter/setter methods based on our DB Schema.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
module ActiveRecord
module AttributeMethods
module ClassMethods
# Generates all the attribute related methods for columns in the database
def define_attribute_methods
#...
super(attribute_names)
end
#...
# Returns an array of column names as strings if it's not an abstract class and
# table exists. Otherwise it returns an empty array.
# Person.attribute_names
# eg: => ["id", "created_at", "updated_at", "name", "age"]
def attribute_names
@attribute_names ||= if !abstract_class? && table_exists?
attribute_types.keys
else
[]
end.freeze
end
end
end
end
The super call ends up invoking the define_attribute_methods
method that lives within ActiveModel::AttributeMethods
, let’s take a look at it:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
module ActiveModel
module AttributeMethods
included do
class_attribute :attribute_method_patterns, instance_writer: false, default: [ ClassMethods::AttributeMethodPattern.new ]
end
#...
module ClassMethods
#...
def define_attribute_methods(*attr_names)
#...
attr_names.flatten.each do |attr_name|
define_attribute_method(attr_name)
end
end
def define_attribute_method(attr_name)
attribute_method_patterns.each do |pattern|
method_name = pattern.method_name(attr_name)
generate_method = "define_method_#{pattern.proxy_target}"
send(generate_method, attr_name.to_s, owner: owner)
end
end
#...
end
#...
end
end
So what’s happening here? The ActiveModel::AttributeMethods
uses the class_attribute helper
inside the included do
block which we saw in the previous post, it defines a class attribute where we’ll save the “attribute method patterns”, we’ll come to this later.
Then, we can find the method we were looking for: define_attribute_methods
which goes through each attribute name and calls define_attribute_method
for each one of them.
This last method goes through each pattern available in the class attribute defined above and dynamically decides what method to call. This “dynamically deciding” will make sense when looking at the AttributeMethodPattern
and how it is used:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class AttributeMethodPattern # :nodoc:
attr_reader :prefix, :suffix, :proxy_target, :parameters
def initialize(prefix: "", suffix: "", parameters: nil)
@prefix = prefix
@suffix = suffix
@proxy_target = "#{@prefix}attribute#{@suffix}"
@method_name = "#{prefix}%s#{suffix}"
#..,
end
def method_name(attr_name)
@method_name % attr_name
end
#...
end
If we only take into account the default AttributeMethodPattern that was added inside ActiveModel::AttributeMethods
with no prefix or suffix, when calling generate_method = "define_method_#{pattern.proxy_target}"
it will return "define_method_attribute"
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
module ActiveModel
module AttributeMethods
included do
class_attribute :attribute_method_patterns, instance_writer: false, default: [ ClassMethods::AttributeMethodPattern.new ]
end
module ClassMethods
#...
def define_attribute_method(attr_name)
attribute_method_patterns.each do |pattern|
method_name = pattern.method_name(attr_name)
generate_method = "define_method_#{pattern.proxy_target}"
# ============================================================
# ==== generate_method will be "define_method_attribute" =====
# ============================================================
send(generate_method, attr_name)
end
end
end
end
end
So when we use send
to call that method, who will answer to define_method_attribute(attr_name)
?
The answer is…… 🥁🥁🥁🥁 The ActiveRecord::AttributeMethods::Read
module that we mentioned above!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
module ActiveRecord
module AttributeMethods
# = Active Record Attribute Methods \Read
module Read
extend ActiveSupport::Concern
module ClassMethods # :nodoc:
private
def define_method_attribute(name, owner:)
"def #{name}" <<
" _read_attribute(#{name}) { |n| missing_attribute(n, caller) }" <<
"end"
end
end
#...
def _read_attribute(attr_name, &block)
#...
end
end
end
end
And there we have getter/reader methods defined for each attribute! But what about the setter/writer methods 🤔? It’s as simple as adding a new attribute_method_pattern 😃! Let’s look at the ActiveRecord::AttributeMethods::Write
module:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
module ActiveRecord
module AttributeMethods
module Write
extend ActiveSupport::Concern
included do
attribute_method_suffix "=", parameters: "value"
end
module ClassMethods
private
def define_method_attribute=(name)
"def #{name}(value)" <<
" _write_attribute(#{name}, value)" <<
"end"
end
end
def _write_attribute(attr_name, value)
#...
end
end
end
end
By adding a new attribute_method_pattern with a suffix of =
ActiveModel::AttributeMethods
will call define_method_attribute=
when iterating over that specific method pattern!
I encourage you to look at the other sub-modules inside ActiveRecord::AttributeMethods
since you’ll see they follow the same pattern:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
module ActiveRecord
module AttributeMethods
included do
initialize_generated_modules
include Read
include Write
include BeforeTypeCast
include Query
include PrimaryKey
include TimeZoneConversion
include Dirty
include Serialization
end
end
end
When do the getter/setter attribute definitions take place?
If you paid close attention, you may have noticed that we never showed who calls the initial define_attribute_methods
method that starts the whole process.
Here’s where Railties comes into play, Railties “ties” all of the Rails components together, and it’s responsible for orchestrating the “initialization” of a Rails application.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
module ActiveRecord
class Railtie < Rails::Railtie
initializer "active_record.define_attribute_methods" do |app|
config.after_initialize do
#...
ActiveSupport.on_load(:active_record) do
descendants.each do |model|
model.define_attribute_methods
end
end
end
end
end
end
And there you have it! We have now defined all of the getter and setter attributes for our models 💪!
Quick 🏊♂️ into ActiveRecord::ModelSchema
So far we supposed we already knew all of the columns in our DB tables, but we’ll quickly unravel how this happens under the hood. Let’s take a quick look at the load_schema!
method inside ActiveRecord::ModelSchema
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
module ActiveRecord
module ModelSchema
#...
module ClassMethods
def load_schema!
columns_hash = connection.schema_cache.columns_hash(table_name)
# ...
end
#...
end
end
end
It’s pretty clear that based on a db connection and a table name it will fetch all of the columns in it. At first, I thought loading the schema would involve reading the famous db/schema.rb
file, but it turns out the schema is loaded by querying the DB itself! For instance, this is how that query looks like for MySQL:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
module ActiveRecord
module ConnectionAdapters
class AbstractMysqlAdapter < AbstractAdapter
#...
def column_definitions(table_name)
execute_and_free("SHOW FULL FIELDS FROM #{quote_table_name(table_name)}", "SCHEMA") do |result|
each_hash(result)
end
end
#...
end
end
end
Conclusion
And that brings us full circle! We’ve explored how ActiveRecord defines getter and setter methods aligned with our database schema and how it dynamically loads this schema.
Ruby on Rails codebase is an awesome place to learn more about Ruby metaprogramming features and how to use it to your advantage; I hope this post has given you a glimpse of that! 🎉