Erweitern eines has_many Assoziation Proxy

Die Assoziationstypen has_many und sein Kollege has_and_belongs_to_many in Ruby on Rails sind jeweils beide erweiterbar. Aus verschiedensten Gründen ist es komfortabel, das Proxy Objekt zu erweitern:

  1. Kapselt Logik, die direkt zur Assoziation gehört
  2. Ermöglicht einen ausdrucksstarken Zugriff auf assoziierte Objekte
  3. Ist mit anderen Collection Proxy Objektmethoden kombinierbar

Das Beispiel

In dem Beispiel hat ein User neben dem Namen auch ein Geburtsdatum:

rails g model User name:string birthday:date
rails g model Event title:string user:belongs_to

Zusätzlich können mehrere Events assoziiert sein:

class Event < ApplicationRecord
  scope :beginning_after, ->(time) { where('starts_at > ?', time) }
end

class User < ApplicationRecord
  has_many :events

  def next_birthday
    current_birthday = birthday.change(year: Date.current.year)
    return current_birthday if current_birthday.future?
    current_birthday + 1.year
  end
end

Die Methode User#next_birthday berechnet den nächsten Geburtstag.
Folgende Query findet alle Events des Users, die nach dessen nächstem Geburtstag beginnen:

user.events.beginning_after(user.next_birthday)
# => [#<Event:0x000000040f17a8 id: 2, ... ]

Es wirkt allerdings etwas unbeholfen, das Datum das nächste Geburtstagsdatum explizit übergeben zu müssen, zumal die events Assoziation schon direkt an dem User hängt.
Etwas umständlich.

Erweiterung der Assoziation

Jede has_many und has_and_belongs_to_many Assoziation kann mit einem Block erweitert und innerhalb dessen auf das proxy_association Objekt zugreifen. Das beinhaltet den owner, also das Objekt selber (in diesem Fall der user):

class User < ApplicationRecord
  has_many :events do
    def after_next_birthday
      beginning_after(proxy_association.owner.next_birthday)
    end
  end

  def next_birthday
    current_birthday = birthday.change(year: Date.current.year)
    return current_birthday if current_birthday.future?
    current_birthday + 1.year
  end
end

Der Zugriff sieht nun sehr viel eleganter und Rails like aus:

user.events.after_next_birthday
# => [#<Event:0x000000040f17a8 id: 2, ... ]

Association Extensions kombinieren

Natürlich lassen sich sämtliche Proxy Methoden miteinander kombinieren.
In dem Beispiel wurde eine weitere Proxy Methode hinzugefügt:

class User < ApplicationRecord
  has_many :events do
    def after_next_birthday
      beginning_after(proxy_association.owner.next_birthday)
    end

    def [](term)
      events_table_name = proxy_association.reflection.klass.table_name
      where("LOWER(#{events_table_name}.title) LIKE ?", "%#{term.to_s.downcase}%")
    end
  end

  def next_birthday
    current_birthday = birthday.change(year: Date.current.year)
    return current_birthday if current_birthday.future?
    current_birthday + 1.year
  end
end

Die Methode ermöglicht eine Filterung der Events nach Titeln (LIKE-Suche). Sicherlich würde man auch diese Selektion in einen Named Scope überführen.
An dieser Stelle wurde sie aber direkt in der Proxy Methode implementiert, um zu zeigen, dass proxy_association noch andere Informationen über die Assoziation liefern kann (reflection). Weiterhin sinnvoll kann association_proxy.target sein.
Eine kombinierte Anfrage:

user.events[:family].after_next_birthday

generiert folgendes SQL:

SELECT events.*
FROM events
WHERE events.user_id = 1 
  AND (LOWER(events.title) LIKE '%family%')
  AND (starts_at > '2017-03-17')

Im Falle, dass weitere Proxy Methoden sinnvoll sind, können damit sehr elegante Assoziationserweiterungen implementiert werden.