DEV Community

James
James

Posted on • Originally published at jsrn.net on

Modules, Macros, Metaprogramming and Magic

If you’ve used the Rails framework, you will probably recognise this:

class Comment < ApplicationRecord
  belongs_to :article
end
Enter fullscreen mode Exit fullscreen mode

This snippet of code implies three things:

1) We have a table of comments.
2) We have a table of articles.
3) Each comment is related to an article by some ID.

Rails users will take for granted that if they have an instance of the Comment class, they will be able to execute some_comment.article to obtain the article that the comment is related to.

This post will give you an extremely simplified look at how something like Rails’ ActiveRecord relations can be achieved. First, some groundwork.

Modules

Modules in Ruby can be used to extend the behaviour of a class, and there are three ways in which they can do this: include, prepend, and extend. The difference between the three? Where they fall in the method lookup chain.

class MyClass
  prepend PrependingModule
  include IncludingModule
  extend ExtendingModule
end
Enter fullscreen mode Exit fullscreen mode

In the above example:

  • Methods from PrependingModule will be created as instance methods and override instance methods from MyClass.
  • Methods from IncludingModule will be created as instance methods but not override methods from MyClass.
  • Methods from ExtendingModule will be added as class methods on MyClass.

We can do fun things with extend.

Executing Code During Interpretation Time

module Ownable
  def belongs_to(owner)
    puts "I belong to #{owner}!"
  end
end

class Item
  extend Ownable
  belongs_to :overlord
end
Enter fullscreen mode Exit fullscreen mode

In the above code, we’re just defining a module and a class. No instance of the class is ever created. However, when we execute just this code in an IRB session, you will see “I belong to overlord!” as the output. Why? Because the code we write while defining a class is executed as that class definition is being interpreted.

What if we re-write our module to define a method using Ruby’s define_method?

module Ownable
  def belongs_to(owner)
    define_method(owner.to_sym) do
      puts self.object_id
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Whatever we passed as the argument to belongs_to will become a method on instances of our Item class.

our_item = Item.first
our_item.overlord
# => 70368441222580
Enter fullscreen mode Exit fullscreen mode

Excellent. You may have heard this term before, but this is “metaprogramming”. Writing code that writes code. You just metaprogrammed.

Tying It Together

You might also notice that we’re getting closer to the behaviour that we would expect from Rails.

So let’s say we have our Item class, and we’re making a videogame, so we’re going to say that our item belongs to a player.

class Item
  extend Ownable
  belongs_to :player
end
Enter fullscreen mode Exit fullscreen mode

Our Rails-like system could make some assumptions about this.

1) There is a table in the database called players.
2) There is a column in our items table called player_id.
3) The player model is represented by the class Player.

Let’s return to our module and tweak it based on these assumptions.

module Ownable
  def belongs_to(owner)
    define_method(owner.to_sym) do
      # We need to get `Player` out of `:player`
      klass = owner.to_s.capitalize.constantize
      # We need to turn `:player` into `:player_id`
      foreign_key = "#{owner}_id".to_sym
      # We need to execute the actual query
      klass.find_by(id: self.send(foreign_key))
      # SELECT * FROM players WHERE id = :player_id LIMIT 1
    end
  end
end

class Item
  extend Ownable
  belongs_to :player
end

my_item = Item.first
my_item.player
# SELECT * FROM players WHERE id = 1 LIMIT 1
# => #<Player id: 12>
Enter fullscreen mode Exit fullscreen mode

Neat.

Top comments (0)