Modelujemy znajomych w Ruby on Rails

05 kwietnia 2008, 17:51:05

Poziom: 0 | Kategoria: Komputerowo-internetowo, Ruby, Ruby on Rails, Techblog.

Ponieważ jestem w trakcie pisania małego serwisu społecznościowego w ramach pracy dyplomowej, nadszedł czas na wymodelowanie sieci znajomych. Przeglądałem trochę książek, szukałem trochę po przeróżnych blogach. W końcu stworzyłem hybrydę z rzeczy, które znalazłem.

Piszę w Railsach 2.0.2, chciałem więc w pełni wykorzystać REST. Pierwsze pytania pojawiły się już na poziomie projektowania bazy danych: czy lepiej podwajać krotki i trzymać w nich jakieś dodatkowe atrybuty? Czy może lepiej oszczędzić miejsce redukując rozmiar tabeli do jednej krotki na znajomość kosztem bardziej złożonego zapytania? Stanęło na tym, że każda "znajomość" ma dwie krotki w tabeli pośredniej, a znajomych przeglądamy przeglądając atrubuty "friend_id" krotek wybranych po atrybucie "user_id". Zatem dostaliśmy relację wiele-wiele pomiędzy tabelą Users a nią samą. Nasz model usera zaczął wyglądać więc mniej więcej tak:

# app/models/user.rb
class User < ActiveRecord::Base
  has_many :friendships, :dependent => :destroy
end

Model samej relacji "być znajomym" nabrał takiego kształtu:

# app/models/friendship.rb
class Friendship < ActiveRecord::Base
  belongs_to :user
  belongs_to :friend, :class_name => 'User', :foreign_key => 'friend_id'
end

Przyjęliśmy, że każde "zaproszenie" może mieć jeden z trzech stanów:

Stan zaproszenia przechowywany więc jest w tabeli pośredniej relacji wiele-wiele. Chcemy również w łatwy sposób docierać do naszych znajomych z poziomu usera. Z pomocą przychodzi nam metoda has_many :through, którą wykorzystujemy w modelu User (dla uproszczenia stany zaproszeń wyrażane są za pomocą stringów, innym sposobem, w moim mniemaniu lepszym, jest użycie, zamiast napisów, np. jakiś stałych liczbowych):

# app/models/user.rb
class User < ActiveRecord::Base
# (...)  
  has_many :friendships, :dependent => :destroy
  
  has_many :friends,
    :through => :friendships,
    :conditions => ["status = ?", 'zaakceptowane']
  
  has_many :requested_friends,
    :through => :friendships,
    :source => :friend,
    :conditions => ["status = ?", 'wyslane']
  
  has_many :pending_friends,
    :through => :friendships,
    :source => :friend,
    :conditions => ["status = ?", 'oczekujace']
  
  has_many :any_friends,
    :through => :friendships,
    :source => :friend
# (...)
end

Teraz dobrze jest zapewnić jakieś metody do zarządzania znajomościami. W tym celu utworzymy metody klasy Friendship, które będą wysyłały, akceptowały i odrzucały zaproszenia do znajomości:

# app/models/friendship.rb
class Friendship < ActiveRecord::Base

  belongs_to :user
  belongs_to :friend, :class_name => 'User', :foreign_key => 'friend_id'
  
  validates_presence_of :user_id, :friend_id
  
  def self.exists?(user, friend)
    not find_by_user_id_and_friend_id(user, friend).nil?
  end
  
  def self.request(user, friend)
    unless user == friend or Friendship.exists?(user, friend)
      transaction do
        create(:user => user, :friend => friend, :status => 'wyslane')
        create(:user => friend, :friend => user, :status => 'oczekujace')
      end
    end
  end
  
  def self.accept(user, friend)
    transaction do
      accepted_at = Time.now
      accept_one_side(user, friend, accepted_at)
      accept_one_side(friend, user, accepted_at)
    end
  end
  
  def self.breakup(user, friend)
    transaction do
      destroy(find_by_user_id_and_friend_id(user, friend))
      destroy(find_by_user_id_and_friend_id(friend, user))
    end
  end
  
  private
  def self.accept_one_side(user, friend, accepted_at)
    request = find_by_user_id_and_friend_id(user, friend)
    request.status = 'zaakceptowane'
    request.accepted_at = accepted_at
    request.save!
  end
end

Jak widać kluczowe akcje modelu wywoływane są w bloku metody transaction. To zapewnia nam transakcyjne wykonanie kodu, co zabda o spójność danych i nie dopuści do powstania sytuacji, w której podczas wysyłania zaproszenia od użytkownika A do użytkownika B w bazie pojawi się tylko krotka (A,B,'wyslane').

Można powiedzieć, że model jest już gotowy, pora zatem na kontroler, który będzie oferował podzbiór standardowych akcji: create, index, show oraz destroy. Akcja index zajmie się wyświetlaniem listy znajomych danego użytkownika, show przekieruje nas na profil użytkownika, create będzie odpowiedzialna za wysłanie lub zaakceptowanie zaproszenia, a destroy pozwoli na usunięcie kogoś z listy znajomych, jak również anulowanie wysłanego zaproszenia czy odrzucenie zaproszenia od niechcianej osoby. Całość umieściłem za filtrem :login_required (korzystam z pluginu restful_authentication) aby zarządzanie znajomymi było dostępne tylko po zalogowaniu.

Poszczególne akcje kontrolera nie są jakoś bardzo wyszukane, dla przykładu wkleję akcję destroy:

# app/controllers/friendships_controller.rb
class FriendshipsController < ApplicationController
# (...)
  def destroy
    # current_user zwraca aktualnie zalogowanego uzytkownika
    @user = current_user # dbamy o to, aby nikt nie usunal za nas naszych znajomych
    @friend = User.find(params[:id]) rescue nil
    if @friend
      if @user.any_friends.include?(@friend)
        Friendship.breakup(@user,@friend)
        flash[:notice] = 'Zaproszenie zostało usunięte'
      else
        flash[:notice] = 'Nie ma takiego zaproszenia'
      end
    else
      flash[:notice] = 'Nie ma takiego użytkownika'
    end
    redirect_to user_friendships_url(@user)
  end
# (...)

Zasób Friendships zagnieżdzony został przeze mnie w zasobie Users, co zapewnia taki fragment routes.rb:

# config/routes.rb
# (...)
 map.resources :users do |u|
    u.resources :friendships
  end
# (...)

Wydaje mi się, że takie rozwiązanie jest jednym z najprostszych, a mimo to zapewnia nam podstawowe rzeczy, które mogą okazać się przydatne, np. to, kto był stroną wysyłającą zaproszenie, czy kiedy zostało ono zaakceptowane.

Tagi:

Komentarze do notki “Modelujemy znajomych w Ruby on Rails”:

  1. Seban

    Jedyną rzeczą, którą bym zmienił to trzymanie statusów znajomości w jakimś hashu: STATUSES { :pending => „”, :accepted => „”, :sent => „” }. Wtedy wszędzie tam gdzie np. potrzebujesz zaakceptowanych znajomych dasz User::STATUSES[:accepted].

  2. GhandaL

    Seban: napisalem, ze statusy jako stringi to uproszczenie :-) W projekcie mam stale, chociaz nie w hashu – dzieki za przypomnienie o jego istnieniu, rzeczywiście w hashu będzie to ładniej wyglądać

  3. Seban

    W jednym projekcie nad którym pracuję używamy tabalic slownikowych. Ma to swoje plusy i minusy, ale takie stałe w klasie według mnie są lepsze.

  4. wijet

    Dla takich modeli ze statusem, idealny jest plugin http://agilewebdevelopment.com/plugins/acts_as_state_machine , jestem w trakcie konczenia wpisu o nim.

  5. Radarek

    Ghandal: fajnie to wszystko wygląda. Po prostu piękny kod, czytam i wszystko wiem co Twoje relacje oznaczają. Na tym polega piękno i potęga Rubiego/Railsów :).

  6. Mack

    Jak wyciągnąć np 30 ostatnich znajomości z serwisu? Problem tkwi w tym że 2 rekordy mówią o tej samej znajomości.

    Można by zrobić DISTINCT na kolumnie accepted_at ale może zdarzyć się tak że zostanie nawiązanych kilka znajomości dokładnie w tym samym czasie.

    Pozdrawiam

  7. GhandaL

    Mack: najprostszym rozwiazaniem, ktore mi w tej chwili przychodzi do glowy, to zrobienie joina z tabela friendship (user_id z friend_id) i wyciagniecie ostatnich 30 rekordow posortowanych po created/accepted_at

  8. Mack

    Ok, mam zapytanie i działa:

    SELECT * FROM friendships as fr1
    JOIN friendships as fr2 ON fr2.user_id = fr1.friend_id AND fr2.status = ‘zaakceptowane’
    WHERE fr1.user_id < fr1.friend_id ADN fr1.status = ‘zaakceptowane’

    Jak to teraz zapisać ładnie w railsach?

  9. GhandaL

    Od razu mowie, ze pisze z glowy, ale powinno byc ok. W kazdym razie idea powinna byc oczywista ;-)

    latest = Friendship.find(
        :all,
        :joins => "JOIN friendships AS fr ON friendships.user_id=fr.friend_id",
        :select => "friendships.*",
        :conditions => ["friendships.id != fr.id AND status=?","zaakceptowane"])
    
  10. GhandaL

    w powyższym kodzie jest pomyłka w conditions (nie taki warunek na id), ale mysle, ze wszystko jest jasne

  11. Ktos

    Kod wygląda żywcem wzięty z książki „RailsSpace: Building a Social Networking Website with Ruby on Rails”

  12. GhandaL

    Napisałem przecież, że stworzyłem hybrydę z rzeczy, które znalazłem i które wymyśliłem. Poza tym w RailsSpace nie było jeszcze REST-owych kontrolerów, bo to w ogóle książka nie pierwszej świeżości ;-)

Zostaw komentarz (Textile włączony):