Skip to Content
Course content

115: Practice Exercise: Building a Simple ActiveRecord-Style ORM

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

A few years ago, I worked with a developer who was tasked with migrating a legacy system that used raw SQL for everything. I remember watching him spend an entire afternoon writing nearly identical INSERT and UPDATE statements for five different tables. He was exhausted, not because the logic was hard, but because the repetition was soul-crushing. I told him, "You're manually mapping data from a hash to a string. Why not just make the object responsible for its own persistence?" That "aha!" moment is exactly why the ActiveRecord pattern exists. It stops you from thinking in rows and columns and lets you think in objects.

Mapping Ruby Classes to Database Tables

The magic of an ORM (Object-Relational Mapper) is the convention it establishes. Instead of telling Ruby which table to use for every single query, we can use the class name itself. If you have a class called User, it's a safe bet that your table is called users. By leveraging self.class.name and a bit of string manipulation, we can automate the table targeting. I've always found that the most elegant way to handle this is to create a base class that handles the heavy lifting, which your specific models then inherit from.

In a real ActiveRecord implementation, the library queries the database schema to know which columns exist. For our simplified version, we'll store attributes in a hash. This keeps the object flexible; we don't need to hardcode every single column as an instance variable, which would be a nightmare to maintain as your schema grows.

The Power of method_missing for Dynamic Attributes

If you've used ActiveRecord, you've noticed you can call user.email even if you never defined an attr_accessor :email in your class. This is where Ruby's metaprogramming shines. By overriding method_missing, we can intercept calls to methods that don't exist and check if they match a key in our attributes hash. It feels like magic the first time you see it, but it's really just a safety net.

def method_missing(method_name, *args, &block)
  if attributes.has_key?(method_name)
    attributes[method_name]
  elsif attributes.has_key?("#{method_name}=")
    # Handle setters here
  else
    super
  end
end

I usually warn people not to over-use method_missing because it can make debugging a headache, but for an ORM, it's the gold standard. It allows your Ruby objects to evolve alongside your database without requiring a code change every time you add a column to a table.

Translating Object State to SQL

The final piece of the puzzle is the save method. The logic here is binary: either the object is new and needs an INSERT, or it already has an ID and needs an UPDATE. This is where you translate the internal attributes hash into a SQL string. You'll want to iterate over the hash keys to build your column list and values list dynamically.

When I build these prototypes, I always emphasize the importance of the where clause. A simple find(id) method is just a wrapper around SELECT * FROM table WHERE id = x. The goal is to ensure that the developer interacting with the class never has to write a single line of SQL; they should just be manipulating Ruby objects and calling .save when they're done.




📋 Practical Task

Exercise: Implementing the MiniORM Base Class

Your task is to build a simplified version of an ActiveRecord-style ORM. You need to create a base class called MiniORM and a subclass called User that uses it to interact with a conceptual database.

Requirements:

  • Create a MiniORM class that initializes with an attributes hash.
  • Implement method_missing in MiniORM so that you can access attribute values via method calls (e.g., user.name instead of user.attributes[:name]).
  • Implement a class method MiniORM.find(id) that returns a new instance of the class with a mocked set of data (simulate a database hit).
  • Implement an instance method save. This method should print a simulated SQL string to the console:
    • If the object has an :id, print: "UPDATE [table_name] SET [columns] WHERE id = [id]"
    • If the object does NOT have an :id, print: "INSERT INTO [table_name] ([columns]) VALUES ([values])"
  • The table_name should be automatically derived from the class name (e.g., User becomes "users").

Testing your implementation:

user = User.new(name: "Alice", email: "alice@example.com")
puts user.name # Should output: Alice
user.save      # Should output: INSERT INTO users (name, email) VALUES (Alice, alice@example.com)

existing_user = User.find(1)
existing_user.name = "Bob" # Implement a basic setter or handle via attributes hash
existing_user.save         # Should output: UPDATE users SET name = Bob... WHERE id = 1
Rating
0 0

There are no comments for now.

to be the first to leave a comment.