Tuesday, June 24, 2008

Counter Cache w ruby on rails dla relacji many-to-many

Rails dostarcza wiele cennych narzędzi, które w nieprawdopodobny sposób potrafią przyspieszyć proces tworzenia aplikacji. Jednym z bardzo użytecznych mechanizmów jest wsparcie dla mechanizmu counter cache.

Aby zrozumieć jak działa ten mechanizm wyobraźmy sobie, że mamy relacje typu jeden do wielu. Dla przykładu w każdym powiecie znajduje się wiele miast.



Jeżeli chcielibyśmy w naszej aplikacji wyświetlać przy każdym powiecie informacje o liczbie miast jaka sie w nim znajduje musielibyśmy za każdym razem wywołać:


Select count(*) from miasta where powiat_id = id


Łatwo się zorientować, że jeżeli liczba miast i powiatów jest duża to takie rozwiązanie jest wysoce nieefektywne. Rozwiązaniem staje się wprowadzenie dodatkowej kolumny do tabeli powiaty zawierającą policzoną wcześniej liczbę miast. Teraz pozostaje jedynie pilnowanieaby przy każdym dodaniu lub usunięciu relacji powiat - miasto informacja w dodatkowej kolumnie została zaktualizowana.

W przypadku przedstawionej tutaj relacji sam Rails czuwa nad tym aby liczba ta pozostała prawidłowa wystarczy, że zrobimy następujące kroki:

1.Zdefiniujemy migracje:


class CreateRelacja < ActiveRecord::Migration
def self.up

create_table :miasta do |t|
t.string :nazwa, :limit => 35
t.integer :miasto_id
t.timestamps
end

create_table :powiaty do |t|
t.string :nazwa, :limit => 35
t.integer :miasta_count, :default => 0
t.timestamps
end

end
end

Uwaga: bardzo ważne jest aby kolumna miasta_count zainicjowana została domyślną wartością 0 – w przeciwnym przypadu licznik nie będzie działał (jeżeli chcesz dodać counter_cache w istniejącej tabeli zobacz tego posta (http://mstanek.blogspot.com/2008/06/dodanie-counter-cache-do-istniejcej.html)).

2.W następnym kroku należy zmodyfikować klasy modelu:


class Miasto < ActiveRecord::Base
validates_presence_of :nazwa, :message => "jest wymagana"
belongs_to :powiat,:counter_cache => true;

end
class Powiat < ActiveRecord::Base
validates_presence_of :nazwa, :message => "jest wymagana"
has_many :miasta
end

Takie rozwiązanie jest szybkie i całkowicie wspierane przez Railsa. Niestety wsparcia takiego nie otrzymujemy dla relacji wiele do wiele.

W tym momencie za model posłuży nam baza usług medycznych informacja-madyczna.pl. Występuje tam związek pomiędzy specjalizacjami a lekarzami. Jest wielu lekarzy posiadających daną specjalizację, a każdy z nich może posiadać więcej niż jedną specjalizację. Jest to ewidentnie związek wiele do wiele.



Nasz model wygląda zatem w następujący sposób:


class Lekarz < ActiveRecord::Base
has_and_belongs_to_many :specjalizacje
end

class Specjalizacja < ActiveRecord::Base
has_and_belongs_to_many :lekarze
end



Aby załóżmy zatem, że chcemy mieć informacje dotyczącą ilości lekarzy z daną specjalizacją. W kodzie informację taką można uzyskać w nastepujący sposób:


Specjalizacja.find_by_nazwa('ginekologia').lekarze.length


Niestety wyciągnięcie w ten sposób informacji dla każdej specjalizacji jest bardzo czasochłonne. Dodajmy zatem kolumnę licznika w tabeli specjalizacje, tak abyśmy mogli wyliczoną w powyższy sposób liczbę zachować. Tworzymy zatem migrację a w migracji dodajemy następujący kod:


add_column :specjalizacje, :lekarze_count, :integer, :default => 0
Specjalizacja.find(:all).each do |s|
s.lekarze_count = s.lekarze.length
s.save
end


Proszę zauważyć, że aby zachować jednolity zapis z wbudowanym mechanizmem couter_cache kolumna została nazwana w analogiczny sposób.

W tym momencie posiadamy już informację o liczności każdego związku – jest ona przechowywana w klasie Specjalizacja. Teraz pozostaje najtrudniejsza część, należy mianowicie zadbać o to, żeby w czasie dodawania bądź usuwania obiektów z relacji licznik ulegał zmianie. Należy tutaj zwrócić uwagę, że modyfikaje tego typu możemy wprowadzać po obu stronach relacji. Pokaże to na przykładzie dodania nowej specjalizacji lekarzowi, którą możemy wykonać na dwa sposoby:


lekarz = Lekarz.find_by_imie_and_nazwisko('Jan','Kowalski')
specjalizacja = Specjalizacja.find_by_nazwa('ginekologia')


1. sposób:

lekarz.specjalizacje << specjalizacja
lekarz.save


2. sposób:

specjalizacja.lekarze << lekarz
specjalizacja.save


Z dokumentacji relacji has_many dowiadujemy się, że posiada ona dwa bardzo pomocne nam modyfikatory :before_add oraz :before_remove. Pozwolą nam one na śledzenie zmian, które powinny zostać odzwierciedlone w wartości licznika. Rozszerzmy zatem naszą klasę Lekarz o śledzenie zmian i poprawę licznika:


class Lekarz < ActiveRecord::Base
has_and_belongs_to_many :specjalizacje,
:before_add => :counter_inc,
:before_remove => :counter_dec


def counter_inc(t)
counter_change(t, 1)
end

def counter_dec(t)
counter_change(t, -1)
end

private
def counter_change(object, amount)
object.lekarze_count = object.lekarze_count+amount;
object.save
end
end


A następnie klasę Specjalizacja


class Specjalizacja < ActiveRecord::Base
has_and_belongs_to_many :lekarze,
:before_add => :counter_inc,
:before_remove => :counter_dec


def counter_inc(t)
t.counter_inc(self)
end

def counter_dec(t)
t.counter_dec(self)
end
end


W tym momencie udało nam się stworzyć mechanizm counter_cache dla relacji wiele do wiele (many-to-many). Oczywiście pozostaje nam napisanie odpowiednich testów, ale to zadanie pozostawiam już czytelnikowi.

No comments: