Martin Fowler recently blogged about the Embedded Document pattern. It is a very convenient way of dealing with JSON documents in web apps, and we are using it for our models in the app we are currently working on.
In this post, we will see how we can abstract away the smaller patterns that recur when using the Embedded Document pattern. Here is the code snippet we will be working with, based on code from Fowler’s post:
class Delivery def initialize(data) @data = data end
def customer @data['customer'] end
def ship_date Date.parse(@data['shipDate']) endend
class Order def initialize(data) @data = data end
def deliveries @data['deliveries'].map { |d| Delivery.new(d) } end
def quantity_for(a_product) item = @data['items'].detect { |i| a_product == i['product'] } item ? item['quantity'] : 0 endend
order_hash = JSON.parse( '{ "id": 1234, "customer": "martin", "items": [ {"product": "talisker", "quantity": 500}, {"product": "macallan", "quantity": 800}, {"product": "ledaig", "quantity": 1100} ], "deliveries": [ { "id": 7722, "shipDate": "2013-04-19", "items": [ {"product": "talisker", "quantity": 300}, {"product": "ledaig", "quantity": 500} ] }, { "id": 6533, "shipDate": "2013-04-18", "items": [ {"product": "talisker", "quantity": 200}, {"product": "ledaig", "quantity": 300}, {"product": "macallan", "quantity": 300} ] } ] }')
order = Order.new(order_hash)p order.deliveriesp order.quantity_for('talisker')We can observe the following recurring patterns in the above code:
- All of these classes need to have a constructor that takes a data object and stores it in a field.
- Many attributes require only a simple lookup in the wrapped hash, for example
Delivery#customer. - Some attributes need a lookup in the hash followed by a transformation, for example
Delivery#ship_date. - In some cases, the nested hashes further need to be wrapped in objects. These nested objects can appear in sequences too, for example
Order#deliveries. - Some attributes and methods involve complex operations and are too specific to derive any generalisation from them, for example
Order#quantity_for.
Let us start by defining a class called EmbeddedDocument, which provides the base for such classes.
class EmbeddedDocument < Struct.new(:data)endThis provides the necessary constructor to a subclass of EmbeddedDocument, thus taking care of item 1.
Before we go further, let us conjure up an abstraction named “embedder”. An embedder is nothing but an object with a method named call that takes one of the possible types in a JSON document, such as a number, a string, an array, a hash, or nil, and returns a more useful representation thereof, potentially wrapping it in some subclass of EmbeddedDocument.
Now we can define an identity embedder, something that returns a value as-is, without any transformations, which we will use for scalar values such as numbers, strings, and nil.
def scalar lambda { |x| x }endThis takes care of item 2.
We can define some common transformations in a similar way and take care of item 3. We will define one for dates to serve as an example.
def date lambda { |x| Date.parse(x) }endWe will deal with item 4 in two steps.
Step 1: We will make the class object associated with every EmbeddedDocument an embedder too. That is, it will have a call method that takes parsed data and returns an instance wrapping it.
class EmbeddedDocument < Struct.new(:data) def self.call(value) new(value) endendStep 2: We will now define a couple of functions that take one embedder and return another one. This is a very powerful technique from functional programming called combinatory design. What we are defining below can be referred to as embedder combinators.
def sequence_of(e) lambda { |array| array.map { |x| e.call(x) } }end
def defaulted(e, default) lambda { |value| value.nil? ? default : e.call(value) }endLet us define a method key that will allow us to specify a key name and the embedder to be used.
class EmbeddedDocument < Struct.new(:data) def key(name, embedder) embedder.call(data[name]) end
def self.call(value) new(value) endendWe now have all the machinery ready. This is how our original classes look with the new utility:
class Delivery < EmbeddedDocument def customer key('customer', scalar) end
def ship_date key('shipDate', date) endend
class Item < EmbeddedDocument def product key('product', scalar) end
def quantity key('quantity', scalar) endend
class Order < EmbeddedDocument def deliveries key('deliveries', sequence_of(Delivery)) end
def items key('items', sequence_of(Item)) end
def quantity_for(a_product) item = items.detect { |i| a_product == i.product } item ? item.quantity : 0 endendBeautiful, isn’t it?
But this is Ruby, and we can do even better. Here are a couple of ways we can improve the solution above:
- Define a method on
EmbeddedDocument’s class that takes only the absolute essentials and defines a JSON accessor method for us. - We have many cases above in which a field access is a simple lookup in the wrapped hash and nothing more. Why not put
method_missingto good use here?
Here is what our EmbeddedDocument class looks like after those improvements:
class EmbeddedDocument < Struct.new(:data) def self.key(name, embedder, accessor_name = nil) name = name.to_s accessor_name ||= name define_method(accessor_name) do embedder.call(data[name]) end end
def self.call(value) new(value) end
def method_missing(sym) data[sym.to_s] endendUse:
class Item < EmbeddedDocumentend
class Delivery < EmbeddedDocument key :shipDate, date, :ship_date key :items, sequence_of(Item)end
class Order < EmbeddedDocument key :deliveries, sequence_of(Delivery) key :items, sequence_of(Item)
def quantity_for(a_product) item = items.detect { |i| a_product == i.product } item ? item.quantity : 0 endendThis is even more concise, and I think it is an improvement on the earlier version.