Tymon Tobolski

Datamapper: asocjajcje polimorficzne (i nie tylko)

Większości znających Ruby on Rails temat “Polymorphic Associations” nie jest zapewne obcy ( z wiki railsów nie działa). W skrócie chodzi o relacje odnoszącą się do obiektów z różnych klas. (Nie umiem tłumaczyć, ale skoro nadal to czytasz to pewnie wiesz o.c.b.)

Przejdźmy do konkretów. O ile w ActiveRecord wystarczy dodac :polymorphic => true to w api Datamappera nie znalazłem takie opcji. Jednak nic nie stoi na przeszkodzie aby napisać to samemu.

Posłużę się pewnym przykładem: chcę aby każda operacja stworzenia nowego obiektu została zapisana. Do tego celu zostanie wykorzystany model Event.

Na początek dwa proste modele, których tworzenie będziemy logować.

class Project
  include DataMapper::Resource
  property :id, Serial
  property :name, String

  after :create do
    Event.create :comment => "Project created", :related => self
  end
end

class Ticket
  include DataMapper::Resource
  property :id, Serial
  property :title, String

  after :create do
    Event.create :comment => "Ticket created", :related => self
  end
end

W każdym z nich został dodany hook (after :create ... ), który będzie wywołany w momencie utworzenia obiektu.

Teraz czas na model Event

class Event
  include DataMapper::Resource

  property :id, Serial
  property :comment, String
  property :created_at, DateTime

  property :related_id, Integer
  property :related_class, String

  def related=(object)
    @related = object
    self.related_id = @related.id
    self.related_class = @related.class.to_s
  end

  def related
    @related ||= Kernel.const_get(related_class).get(related_id)
  end
end

Pole related_id przechowuje id obiektu a related_class nazwę jego klasy. Istotnym elementem jest tutaj rubyKernel.const_get(related_class).get(related_id) Metoda Kernel#const_get zwraca stałą (w naszym przypadku klasę) na podstawie podanej nazwy. (Innym sposobem byłoby użycie eval)

Zobaczmy co nam to wszystko dało:

@project1 = Project.create :name => 'Project 1'
# => #<Project id=1 name="Project 1">
@ticket1 = Ticket.create :title => 'Ticket 1'
# => #<Ticket id=1 title="Ticket 1">

Event.all
# => [#<Event id=1 comment="Project created" related_id=1 related_class="Project">, #<Event id=2 comment="Ticket created" related_id=1 related_class="Ticket">]

Event.get(1).related
# => #<Project id=1 name="Project 1">
Event.get(1).related.name
# => "Project 1"

Event.get(2).related
# => #<Ticket id=1 title="Ticket 1">
Event.get(2).related.title
# => "Ticket 1"

Aby umilić sobie trochę życie możemy dorzucić jeszcze method_missing do klasy Event

def method_missing(method_name, *args, &block)
  if method_name.to_s == self.related_class.downcase
    return related
  end
  super
end

(Więcej na temat method_missing u radarka)

Pozwoli to na użycie:

Event.get(1).project
# => #<Project id=1 name="Project 1">

# ale już nie

Event.get(1).ticket
# => undefined method `ticket' for #<Event:0x111a2c8> (NoMethodError)

Jednej rzeczy brakuje - lista eventów dla danego obiektu:

class Project
  include DataMapper::Resource
  property :id, Serial
  property :name, String

  after :create do
    Event.create :comment => "Project created", :related => self
  end

  def events
    Event.all(:related_id => self.id, :related_class => "Project")
  end
end

class Ticket
  include DataMapper::Resource
  property :id, Serial
  property :title, String

  after :create do
    Event.create :comment => "Ticket created", :related => self
  end

  def events
    Event.all(:related_id => self.id, :related_class => "Ticket")
  end
end

@project1.events
# => [#<Event id=1 comment="Project created" related_id=1 related_class="Project">]
@ticket1.events
# => [#<Event id=2 comment="Ticket created" related_id=1 related_class="Ticket">]

Wszystko pięknie działa, jednak niektórzy mogli zauważyć, że część kodu się powtarza - zróbmy coś z tym!

Hook after :create oraz metode events można by wyrzucić do osobnego modułu a następnie dołączyć go do naszych modeli

module Eventable
  def self.included(base)
    base.class_eval do
      after :create do
        Event.create :comment => event_comment, :related => self
      end
    end
  end

  def events
    Event.all(:related_id => self.id, :related_class => self.class.to_s)
  end
end

class Project
  include DataMapper::Resource
  include Eventable
  property :id, Serial
  property :name, String

  def event_comment
    "Project created"
  end
end

class Ticket
  include DataMapper::Resource
  include Eventable
  property :id, Serial
  property :title, String

  def event_comment
    "Ticket created"
  end
end

Metoda event_comment została dodana aby było możliwe podanie rożnego komentarza do eventu dla każdego modelu z osobna.

I takim oto dość prostym sposobem otrzymujemy przejrzysty i całkiem sprytny kawałek kodu :)

Na deser całość z jednym pliku gotowe do uruchomienia.


comments powered by Disqus

Read more