Reading Rails - The Adapter Pattern
Today instead of reading a specific piece of code, we’re going to do a survey of a common design pattern implemented by Rails, the Adapter Pattern. This isn’t exhaustive by any means, but I hope to highlight some practical examples.
To follow along, open each library in your editor with qwandry, or just look it up on Github.
The Adapter Pattern
The Adapter Pattern can be used to wrap different interfaces, and provide a uniform one, or make one object look like another type of object. In statically typed languages, this is often required to satisfy the type system, but in more loosely typed languages like Ruby, we aren’t forced to use it. All the same, it has plenty of value for us.
When using third party classes or libraries, we often start out fine:
def find_nearest_restaurant(locator)
locator.nearest(:restaurant, self.lat, self.lon)
end
We assume one interface for locator
, but what if we want find_nearest_restaurant
to support another library? We might be tempted to add a special case:
def find_nearest_restaurant(locator)
if locator.is_a? GeoFish
locator.nearest(:restaurant, self.lat, self.lon)
elsif locator.is_a? ActsAsFound
locator.find_food(:lat => self.lat, :lon => self.lon)
else
raise NotImplementedError, "#{locator.class.name} is not supported."
end
end
This is a pragmatic solution. Perhaps we will never support another library. Perhaps find_nearest_restaurant
is the only place we use a locator
.
What happens if you need to support a new locator
though? Now you have three special cases. What if you need to implement find_nearest_hospital
? Now you have two places where you need to maintain these special cases. When you reach the point where this becomes untenable, you should consider the Adapter Pattern.
In this case, we could write GeoFish
and ActsAsFound
adapters so the rest of our code doesn’t need to know which library we are using:
def find_nearest_hospital(locator)
locator.find :type => :hospital,
:lat => self.lat,
:lon => self.lon
end
locator = GeoFishAdapter.new(geo_fish_locator)
find_nearest_hospital(locator)
Enough with the contrived examples, let’s see some real code.
MultiJSON
ActiveSupport’s JSON decoding uses MultiJSON, an adapter for JSON libraries. Each library parses JSON, but does so slightly differently. Let’s look at adapters for oj and yajl.
module MultiJson
module Adapters
class Oj < Adapter
#...
def load(string, options={})
options[:symbol_keys] = options.delete(:symbolize_keys)
::Oj.load(string, options)
end
#...
The Oj Adapter modifies the options
hash, translating :symbolize_keys
to Oj’s :symbol_keys
option using Hash#delete
:
options = {:symbolize_keys => true}
options[:symbol_keys] = options.delete(:symbolize_keys) # => true
options #=> {:symbol_keys=>true}
This is a convenient way to update a Hash in place if you need to adapt options.
Next MultiJSON calls ::Oj.load(string, options)
. MultiJSON’s adapted API is quite similar to Oj’s, so not much is going on here. Did you notice how Oj was referenced though? ::Oj
references the top level Oj
class instead of MultiJson::Adapters::Oj
.
Now let’s look at how MultiJSON adapts the Yajl library:
module MultiJson
module Adapters
class Yajl < Adapter
#...
def load(string, options={})
::Yajl::Parser.new(:symbolize_keys => options[:symbolize_keys]).parse(string)
end
#...
This adapter implements load
differently. With Yajl, a parser instance is created, and Yajl::Parser#parse
is called on the string. The options hash is also handled a little differently. The only option passed to Yajl is :symbolize_keys
.
These JSON adapters may look trivial, but they allow you to switch between libraries without having to update every place you parse JSON.
ActiveRecord
JSON libraries tend to follow very similar patterns, which makes adapting them fairly easy. What if you are dealing with something more complex? ActiveRecord contains adapters for several databases. Although PostgreSQL and MySQL are both SQL databases, there are many differences between them that ActiveRecord hides using the Adapter Pattern.
Look in ActiveRecord’s lib/connection_adapters
directory; there are adapters for PostgreSQL, MySQL, and SQLite. There is also an AbstractAdapter
, which serves as the base class for each specific adapter. The AbstractAdapter
implements common functionality found in most databases and is customized by subclasses such as the PostgreSQLAdapter
and the AbstractMysqlAdapter
, which in turn acts as a common parent for two different MySQL adapters, the MysqlAdapter
and Mysql2Adapter
. Let’s see how this all fits together with a few real world examples.
PostgreSQL and MySQL implement slightly different dialects SQL. While a SELECT * FROM users
will behave the same in both databases, there are subtle differences in how they handle some types. The time format in MySQL and PostgreSQL is slightly different. While PostgreSQL supports microseconds, MySQL has only recently supported them in a stable release. How do the two adapters handle this discrepancy?
ActiveRecord quotes dates using the quoted_date
helper in ActiveRecord::ConnectionAdapters::Quoting
, which is mixed into the AbstractAdapter
. The AbstractAdapter
’s implementation just formats the date:
def quoted_date(value)
#...
value.to_s(:db)
end
Rails’ ActiveSupport extends Time#to_s
to accept a symbol representing a named format. The :db
format is %Y-%m-%d %H:%M:%S
:
# Examples of common formats:
Time.now.to_s(:db) #=> "2014-02-19 06:08:13"
Time.now.to_s(:short) #=> "19 Feb 06:08"
Time.now.to_s(:rfc822) #=> "Wed, 19 Feb 2014 06:08:13 +0000"
None of the MySQL adapters override quoted_date
, so they will all get this behavior. The PostgreSQLAdapter
however makes two changes to the date:
def quoted_date(value)
result = super
if value.acts_like?(:time) && value.respond_to?(:usec)
result = "#{result}.#{sprintf("%06d", value.usec)}"
end
if value.year < 0
result = result.sub(/^-/, "") + " BC"
end
result
end
It starts off by calling super
, so it will get a date formatted just like MySQL. Next it checks to see if the value
is similar to a time. This is a special method ActiveSupport has added which various Time
like classes will return true
to. This makes it easier to declare that various objects should be assumed to be similar to a Time
.
The second half of the condition checks to see if the value
has a usec
method, which returns microseconds. If microseconds are available then they are tacked onto the end of the result string using sprintf
. Just as there are multiple time formats, there are many different ways of formatting numbers with sprintf
:
sprintf("%06d", 32) #=> "000032"
sprintf("%6d", 32) #=> " 32"
sprintf("%d", 32) #=> "32"
sprintf("%.2f", 32) #=> "32.00"
Finally, if the date is negative, the PostgreSQLAdapter
will reformat it to add “BC”, which is indeed required for PostgreSQL:
SELECT '2000-01-20'::timestamp;
-- 2000-01-20 00:00:00
SELECT '2000-01-20 BC'::timestamp;
-- 2000-01-20 00:00:00 BC
SELECT '-2000-01-20'::timestamp;
-- ERROR: time zone displacement out of range: "-2000-01-20"
This is just one tiny way that ActiveRecord adapts multiple APIs, but prevents you from having to worry about many of the details in different databases.
Another way SQL databases differ is in how tables are created. Primary keys are treated differently in MySQL and PostgreSQL:
# AbstractMysqlAdapter
NATIVE_DATABASE_TYPES = {
:primary_key => "int(11) DEFAULT NULL auto_increment PRIMARY KEY",
#...
}
# PostgreSQLAdapter
NATIVE_DATABASE_TYPES = {
primary_key: "serial primary key",
#...
}
Both adapters understand ActiveRecord’s notion of a primary_key
, but they translate that into different SQL when creating a new table. Next time you write a migration, or run a query think about ActiveRecord’s adapters and all the little things they deal with for you.
DateTime and Time
While MultiJson and ActiveRecord implement traditional adapters, Ruby’s flexibility allows another solution. DateTime
and Time
both represent instants in time, but they handle it differently internally. The APIs they expose are similar, though there are subtle differences:
t = Time.now
t.day #=> 19 (Day of month)
t.wday #=> 3 (Day of week)
t.usec #=> 371552 (Microseconds)
t.to_i #=> 1392871392 (Epoch secconds)
d = DateTime.now
d.day #=> 19 (Day of month)
d.wday #=> 3 (Day of week)
d.usec #=> NoMethodError: undefined method `usec'
d.to_i #=> NoMethodError: undefined method `to_i'
ActiveSupport smooths some of these differences away by directly modifying the DateTime
and Time
classes by adding the missing methods. For instance, here is how ActiveSupport defines DateTime#to_i
:
class DateTime
def to_i
seconds_since_unix_epoch.to_i
end
def seconds_since_unix_epoch
(jd - 2440588) * 86400 - offset_in_seconds + seconds_since_midnight
end
def offset_in_seconds
(offset * 86400).to_i
end
def seconds_since_midnight
sec + (min * 60) + (hour * 3600)
end
end
Each of the support methods, seconds_since_unix_epoch
, offset_in_seconds
, and seconds_since_midnight
use or extend DateTime
’s existing API to add methods that match those of Time
.
If the adapters we looked at previously are external to the objects being adapted, then this could be considered an internal adapter. Unlike an external adapter though, this approach works within the confines of the existing API, and can lead to some awkward inconsistencies. For instance DateTime
and Time
still behave differently under some circumstances:
datetime == time #=> true
datetime + 1 #=> 2014-02-26 07:32:39
time + 1 #=> 2014-02-25 07:32:40
While adding 1 to a DateTime
increments by a day, adding 1 to a Time
increments by a second. ActiveSupport layers on a variety of methods like change
and the Duration
value class which provide unified behavior as long as you remember to use them.
Is this a good pattern? It certainly is a convenient, but as we’ve seen you still need to be aware of some differences.
Recap
Design patterns aren’t just for Java. Rails uses the Adapter Pattern to provide a uniform interface for JSON parsing and database manipulation. Due to Ruby’s flexibility, classes such as DateTime
and Time
can be directly modified to provide more similar interfaces. The Rails source is a great place to find real world examples of various design patterns.
We also saw a few interesting bits of code along the way:
hash[:foo] = hash.delete(:bar)
is a handy way to rename Hash entries.- Calling
::ClassName
references the top level class. - ActiveSupport adds an optional format argument to
Time
,Date
, and other classes. sprintf
can be used to format numbers.
Want to see more? Go back and look at how MultiJson handles formatting as well as parsing. Peruse the ActiveRecord adapter for the database of your choice. Peek inside ActiveSupport’s XmlMini
for xml adapters that are similar to the JSON adapters in MultiJson. There’s plenty to learn out there.
More articles in this series
- Reading Rails - HTTP DELETEs With a Link
- Reading Rails - Time Travel
- Reading Rails - TimeWithZone
- Reading Rails - How Does MessageEncryptor Work?
- Reading Rails - How Does MessageVerifier Work?
- Reading Rails - How Do Batched Queries Work?
- Reading Rails - The Adapter Pattern
- Reading Rails - Errors and Validators
- Reading Rails - How Validations Are Configured
- Reading Rails - Concern
- Reading Rails - More Migrations
- Reading Rails - Migrations
- Reading Rails - Attribute Methods
- Reading Rails - Change Tracking
- Reading Rails - Handling Exceptions