Skip to Content
Course content

240: Building a Simple ORM-Like DSL from Scratch

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Imagine you're filling out a custom order form at a high-end sandwich shop. You don't walk into the kitchen and tell the chef, "First, take a slice of sourdough, then apply 2 grams of Dijon mustard, then place three slices of heirloom tomato on top." That would be tedious and prone to error. Instead, you just check a few boxes: [x] Sourdough, [x] Dijon, [x] Tomato. You've provided a high-level description of what you want, and the chef—who knows the "internal logic" of the kitchen—handles the actual implementation of building that sandwich.

A Domain Specific Language (DSL) in Ruby works exactly like that order form. When we use an ORM (Object-Relational Mapper) like ActiveRecord, we aren't writing the SQL to create tables or the Ruby code to map columns to variables manually. We're just "checking boxes" using a DSL, and the ORM handles the heavy lifting behind the scenes.

Turning Class Declarations into Data

The secret to building a DSL is realizing that class-level methods can be used to store metadata. When you call a method inside a class definition but outside any instance method, you're executing code the moment the class is loaded. We can use this to "register" what our model should look like.

Let's start by building a BaseModel. I want my subclasses to be able to say column :name, :string, and have that information remembered by the class.

class BaseModel
  def self.column(name, type)
    # We store the column definitions in a class-level hash
    @columns ||= {}
    @columns[name] = type
    puts "Registered column #{name} as #{type}"
  end

  def self.columns
    @columns || {}
  end
end

class User < BaseModel
  column :username, :string
  column :email, :string
  column :age, :integer
end

If you run this, you'll see the "Registered column" messages pop up immediately. We haven't even created a User.new yet, but the User class now "knows" its own schema. This is the "order form" part of our analogy.

The Magic of Dynamic Method Definition

Knowing the columns is great, but it's useless if we still have to write attr_accessor :username for every single column. That defeats the purpose of the DSL. I want the column method to actually build the getter and setter methods for me while the class is being defined.

This is where define_method comes in. It allows us to create methods programmatically. I've always found this part of Ruby a bit mind-bending at first, but once it clicks, you realize you can generate hundreds of methods in a few lines of code.

class BaseModel
  def self.column(name, type)
    @columns ||= {}
    @columns[name] = type

    # Define the getter
    define_method(name) do
      instance_variable_get("@#{name}")
    end

    # Define the setter
    define_method("#{name}=") do |value|
      # Here is where we'd normally add type validation
      instance_variable_set("@#{name}", value)
    end
  end

  def self.columns
    @columns || {}
  end
end

# Now, let's see it in action
class Product < BaseModel
  column :title, :string
  column :price, :float
end

laptop = Product.new
laptop.title = "MacBook Pro"
laptop.price = 2499.99

puts "#{laptop.title} costs #{laptop.price}" 
# Output: MacBook Pro costs 2499.99

Bridging the Gap to a Mock Database

To make this feel like a real ORM, we need a way to "save" this data. Since we aren't plugging in a real PostgreSQL or MySQL database for this lesson, we'll simulate one using a global hash. The goal is to map our object's instance variables back to the "columns" we defined in our DSL.

Notice how I'm using the self.class.columns call inside the save method. This allows the instance to look back at the "blueprint" we created at the class level to determine what data actually belongs in the database.

$mock_db = {}

class BaseModel
  # ... (column and define_method logic from above) ...

  def save
    # Use the class-level metadata to build a data hash
    data = {}
    self.class.columns.each_key do |col_name|
      data[col_name] = send(col_name)
    end
    
    # Simulate saving to a DB using the object's memory address as an ID
    id = self.object_id
    $mock_db[id] = data
    puts "Saved to DB: {$mock_db[id]}"
    id
  end
end

By combining class-level state, define_method, and instance_variable_set, we've essentially built a miniature version of the engine that powers Rails. You're no longer writing boilerplate; you're writing a language that describes your data, and letting Ruby handle the implementation.




📋 Practical Task

Implement a Type-Checking Validator for the Mini-ORM

Right now, our BaseModel allows you to set any value to any column, regardless of the type we specified in the column :name, :type call. For example, you could set a :integer column to a String, and the ORM wouldn't care.

Your Task: Modify the column method in the BaseModel class so that the dynamically generated setter method validates the input type before assigning the value.

  • If the column type is :integer, the setter should raise an ArgumentError if the value provided is not an Integer.
  • If the column type is :string, the setter should raise an ArgumentError if the value provided is not a String.
  • If the column type is :float, the setter should raise an ArgumentError if the value provided is not a Float.

Test your implementation by creating a User model with an :age column of type :integer and attempting to assign a string to it. The code should crash with the appropriate error message.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.