teaching machines

CS 330: Lecture 37 – Object-relational Mapping Via Metaprogramming

Dear students,

Metaprogramming is our theme for this last week of the semester. What is metaprogramming? It’s when code generates code. It’s when user input is used to generate new classes, methods, and so on. You’ll probably all felt at certain points that the code you are writing probably didn’t need to be written by a human. Perhaps its repetitive. Perhaps its a direct translation of some other structure found elsewhere in your technology stack.

This class has largely been about how we as developers model ideas about computation using programming languages. We’ve mostly ignored the end user and focused on how we can make programming more enjoyable, more efficient, and more intellectually stimulating. Metaprogramming certainly fulfills all these goals.

We will examine metaprogramming in both Ruby and Java. Today, we will concentrate on Ruby. In particular, we will construct a lightweight system for object-relational mapping. In an ORM, you’ve got a representation of some data type in one system, and you’d like to use that same type in another system. Certainly you could specify the type twice, but that’s prone to error. The two specifications will inevitably become unsynchronized.

ORM is often used with databases and the languages that access them. You might write a schema to describe a table in SQL, and you want the code to automatically understand the schema without you having to do any extra work. Here are some things that our ORM should be able to handle:

  • deserializing
  • serializing
  • reading and writing of properties, including properties that are themselves objects

A full ORM will support more features, like relations to other records. Our database will not be a database but a simpler JSON file:

{
  "headline": "Americans Scroll 2.5 Miles Per Day",
  "nwords": 849,
  "author": "Petey F.",
  "copy": "...",
  "tags": ["internet", "millenials"]
}

Out of the box, we can get reasonably close to turning this text into a Ruby object simply by parsing it using the JSON API:

json = File.read(ARGV[0])
article = JSON.parse(json, symbolize_names: true)
puts article[:author]

But this isn’t really an object. It’s a dictionary/hash/key-value pair manager. It’d be sweet if we could make Ruby more like Javascript, where there’s an equivalence between dictionary lookup and field access:

var foo = {};
foo['name'] = 'Scout';
console.log(foo.name);

We’d like to make our Ruby ORM build structures that feel more like an object. Instead of saying this:

article[:author] = value

we’d like to be able to say this:

article.author = value

Let’s do it. We’ll need a new class to support this:

class Autobject
  # ...
end

What happens currently when we try to read a property of Autobject?

article = Autobject.new
puts article.author

When we run this code, we see that method author cannot be found. Is there any way that we can write a class that has methods for every field/property that our clients may want to assign? No way. We can’t see that far into the future.

For this to happen, we need some metaprogramming. We need to generate these methods on the fly based on our schema. But first, let’s write a minimal constructor for testing out some ideas:

def initialize value = {}
  @data = value
end

If the client provides no value, we’ll just wrap around an empty dictionary, a blank data store.

Now, how do we handle all these infinite methods that are impossible to write? Easy. A catch-all method that Ruby will call on our objects when they don’t support a method. It’s called method_missing:

def method_missing symbol, *args
  ...
end

What might be true when this method is called?

  1. It might be a read operation for a key already in the dictionary.
  2. It might be a read operation for a key not already in the dictionary.
  3. It might be a write operation.

Let’s handle the first case:

if @data.has_key? symbol
  @data[symbol]
end

If the key doesn’t exist, let’s raise an exception:

else
  raise "No such property: #{symbol.to_s}"
end

The last case is a bit more involved. We have to check if the method name suggests an assignment, but this needs to be done on the string version of the symbol:

elsif symbol.to_s =~ /^(\w+)=$/ && args.length == 1
  @data[$1.to_sym] = args[0]
end

Okay, let’s test this out:

f = Autobject.new first: 'Roy', last: 'Biv'
f.middle = 'G'
puts "#{f.first} #{f.middle} #{f.last}"

Now let’s get this to work with JSON data. Let’s add a static method for loading an Autobject from some other source:

def self.load src
  ...
end

If we have a URI or File, we’ll open it and slurp up the JSON contents. Otherwise, we’ll assume we have a JSON string. Once we know we have JSON, we can parse it:

if src.is_a?(URI) || src.is_a?(File)
  json = open(src) do |io|
    io.read
  end
else
  json = src
end

Autobject.new(JSON.parse(json, symbolize_names: true))

Now, let’s try reading some JSON:

g = Autobject.load '{"first": "Roy", "last": "Biv"}'
puts g.inspect

And some weather data:

weather = Autobject.load(URI("http://api.openweathermap.org/data/2.5/weather?q=Eau%20Claire%2CWisconsin&APPID=#{KEY}"))
puts weather.inspect

Let’s try to pull out the current temperature:

puts weather.main.temp

This fails. Why? weather.main gives back a Hash. Calling temp on a Hash is bound to fail. We want main to also be an Autobject so we can access fields inside of it with the . operator. Let’s write a little recursive helper that goes and turns all the parts of our JSON structure into Autobjects:

def self.objectify data
  if data.is_a? Array
    data.map do |element|
      Autobject.objectify(element)
    end
  elsif data.is_a? Hash
    object = data.transform_values do |value|
      Autobject.objectify(value)
    end
    Autobject.new object
  else
    data
  end
end

And then in load, we can apply this method to the data that we parse:

data = JSON.parse(json, symbolize_names: true)
Autobject.objectify(data)

Our temperature reading should work now! How about the time of the sunset?

puts Time.at(weather.sys.sunset)

What’s really nice about metaprogramming is that the code is essentially writing itself. This Autobject should be able to handle any key and any dictionary-ish object. Let’s try getting the high and low temperatures:

weather = Autobject.load(URI("http://api.openweathermap.org/data/2.5/forecast/daily?units=imperial&q=Eau%20Claire%2CWisconsin&APPID=#{KEY}"))
puts weather.list[0].temp.min
puts weather.list[0].temp.max

Let’s try dumping the object en masse to the console:

def inspect
  JSON.pretty_generate(@data)
end

This seems to only work for the top-level dictionary. The pretty_generate method recursively calls to_json on all the values nested inside our object, so we also need to provide that method:

def to_json generator
  JSON.pretty_generate(@data, generator)
end  

Of course, now that we have the ability to print an object, we should provide a method for dumping it out to disk:

def save path
  open(path, 'w') do |file|
    file.puts inspect
  end
end

Now we have automatic getters, setters, serialization, and deserialization for any sort of object we can dream up. Thanks, metaprogramming.

See you next time!

Sincerely,

P.S. It’s time for a haiku!

Taught my car to drive
Now when we go on roadtrips
Two can ride shotgun

P.P.S. Here’s the code we wrote together!

article.json

{
  "headline": "Americans Scroll 2.5 Miles Per Day",
  "nwords": 849,
  "author": "Petey F.",
  "copy": "...",
  "tags": ["internet", "millenials"]
}

meta.rb

#!/usr/bin/env ruby

require 'json'
require 'open-uri'

json = File.read(ARGV[0])
article = JSON.parse(json)

# puts article
# puts "article.class: #{article.class}"

class Auto
  def initialize data = {}
    @data = data
  end

  def method_missing symbol, *args
    if @data.has_key?(symbol)
      @data[symbol]
    elsif symbol[-1] == '='
      @data[symbol.to_s.chop.to_sym] = args[0]
    else
      raise "No such property #{symbol}... I have failed you. No, it's me."
    end
  end

  def self.load(src)
    if src.is_a?(File) || src.is_a?(URI)
      json = open(src) do |io|
        io.read
      end
    else
      json = src
    end

    data = JSON.parse(json, symbolize_names: true)
    Auto.objectify(data)
  end

  def inspect
    JSON.pretty_generate(@data)
  end

  def self.objectify data
    if data.is_a? Array
      data.map do |element|
        Auto.objectify(element)
      end
    elsif data.is_a? Hash
      object = data.transform_values do |value|
        Auto.objectify(value)
      end
      Auto.new object
    else
      data
    end
  end
end

article = Auto.load(File.new(ARGV[0]))
puts "article.author: #{article.author}"
puts "article.nwords: #{article.nwords}"
puts "article.headline: #{article.headline}"

# article = Auto.new
# article.author = "H.G. Wells"
# p article
# puts article.author

# puts article['headline']
# puts article.headline

KEY = 'INSERT-YOUR-KEY-HERE'
weather = Auto.load(URI("http://api.openweathermap.org/data/2.5/weather?q=Eau%20Claire%2CWisconsin&APPID=#{KEY}"))
p Time.at(weather.sys.sunset)

# puts weather.main.temp
# p weather

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *