Beyond Active Record merge

Written . Tagged Object design, Ruby on Rails.

If you want maintainable, loosely coupled classes, you must mind what they know about one another.

For example, you don’t want one Active Record model to know a lot about the schema of another.

Using merge

Instead of this:

app/models/user.rb
1
2
3
4
class User < ActiveRecord::Base
  has_one :subscription
  scope :with_active_subscription, joins(:subscription).where("subscriptions.active" => true)
end
app/models/subscription.rb
1
2
class Subscription
end

You might use Active Record’s merge and do something like:

app/models/user.rb
1
2
3
4
class User < ActiveRecord::Base
  has_one :subscription
  scope :with_active_subscription, joins(:subscription).merge(Subscription.active)
end
app/models/subscription.rb
1
2
3
class Subscription
  scope :active, where(active: true)
end

We’re still coupled, of course, but to a higher and more stable abstraction.

Beyond merge

For more complex SQL, merge won’t cut it.

Today, I extracted columns like invoices.posted_at and contracts.emailed_at to a separate events table.

Now there are Event records which belong to a record (invoice or contract) and have an event name and a timestamp.

So a posted invoice may be represented by an Event record with these attributes:

1
{ record_id: 123, record_type: "Invoice", name: "posted", happened_at: "2013-02-28 16:00" }

Now, if you want a scope/method like Invoice.unposted (and perhaps Contract.not_emailed), how would you go about it?

You’ll need a join that involves events columns.

You could put the join SQL in Invoice, but then it would know a lot about the events table. And you’d have to duplicate much of that SQL if Contract adds a similar method.

Instead, you can simply have Event own that SQL:

app/models/event.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Event < ActiveRecord::Base
  belongs_to :record, polymorphic: true

  def self.scope_unevented(scoped, event)
    klass = scoped.klass

    query = [
      "LEFT JOIN events
        ON events.record_id = #{klass.table_name}.#{klass.primary_key}
        AND events.record_type = ?
        AND events.name = ?",
      klass.base_class.name,  # STI uses the base class.
      event
    ]

    scoped.
      joins(sanitize_sql(query)).
      where("events.id IS NULL")
  end
end
app/models/invoice.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Invoice
  POSTED_EVENT = "posted"

  scope :loving_it, where(loving_it: true)

  def self.unposted
    # `scoped` is `Invoice.scoped`; a scope of all records.
    Event.scope_unevented(scoped, POSTED_EVENT)
  end

  def self.unposted_and_loving_it
    Event.scope_unevented(loving_it, POSTED_EVENT)
  end
end

Now all Event needs to know is that its record has a table name, a primary key and a class name, as any Active Record model will. These are fairly stable assumptions.