Symfony + doctrine: Piszemy własnego behaviora.

Symfony + doctrine: Piszemy własnego behaviora.

Dzisiaj krótki tutorial jak napisać własnego behaviora w Doctrine pod Symfony 1.4. Aby spróbować wszystkiego po trochu, behavior będzie dodawał pole do bazy, metodę do modelu oraz modyfikował zapytania (preDQL).

Behaviorem będzie: sortowanie.

Zanim zacznę – krótki wstęp o behaviora. Behavior to coś, co modyfikuje zachowanie modelu. Bardzo dobrym przykładem jest tutaj Timestampable. Możemy w schema.yml dodać wpis, który zmodyfikuje zachowanie się obiektów, dzięki czemu będą one miały dodatkowe pola created_at oraz updated_at, któe będą same uaktualniały się po wywołaniu metody ->save(). Nie musimy wtedy dbać o aktualizowanie pól samemu.

Podobnie będzie z naszym sortowaniem. Załóżmy, że mamy bardzo prostego CMS’a, który posiada artykuły oraz kategorie:

article:
  columns:
    title:       { type: string(255) }
    content:     { type: string }
    category_id: { type: integer }
  relations:
    category:
      local: category_id

category:
  columns:
    name:        { type: string(255) }

Załóżmy, że chcemy móc ustawiać kolejność artykułów. Oczywiście można do modelu dodać odpowiednie metody, do schema dodać pola i cieszyć się rozwiązaniem. Co jednak w przypadku, gdy zechcemy sortwać także kategorie? Wtedy znowu dodajemy pola, metody? Odpowiedź brzmi – NIE.

Do dzieła!

Aby móc korzystać z behaviora, musimy najpierw go stworzyć. Stwórzmy w katalogu lib/behavior (jeżeli katalog nie istnieje – tworzymy go) plik Sortable.class.php:

class Sortable extends Doctrine_Template
{
  protected $_options = array();

  public function __construct($options)
  {
    $this->_options = $options;
  }

  public function setTableDefinition()
  {
  }
}

Kolejność musimy oczywiście gdzieś przechowywać. Dodajmy zatem pole my_order. Czemu my_order a nie order? Doctrine w domyślnej instalacji symfony nie przechowuje nazw pól w cudzysłowiach przez co order nie będzie traktowany jako nazwa pola ale jako zarezerwowane słowo. Efekt? Błąd parsowania. Możemy oczywiście zmusić Doctrine do dodawania cudzysłowiów poprzez ustawienie w bootstrapie:

$conn->setAttribute(Doctrine_Core::ATTR_QUOTE_IDENTIFIER, true);

Niemniej jednak, na aktualne potrzeby nie będziemy sobie tym zawracać głowy.

Modyfikujemy metodę SetTableDefinition dodając:

$this->hasColumn('my_order', 'integer');

W schema.yml dodajemy behaviora:

article:
  actAs: [Sortable]
  columns:
    title:       { type: string(255) }
    content:     { type: string }
    category_id: { type: integer }
  relations:
    category:
      local: category_id

Po wykonaniu ./symfony doctrine:build –all –and-load sprawdźmy sobie wygląd tabeli w bazie:

Co się stało? Pomimo, że nie dodaliśmy pola w schema.yml, to jednak ono się pojawiło. Przy tworzeniu modelu, Doctrine ładuje behaviory i wykonuje je. Zapis $this->hasColumn() powoduje dodanie kolejnego pola do tabeli. W ten sposób możemy modyfikować strukturę.

Ok, to teraz czas na kodowanie właściwe:

W pliku Sortable.class.php dodajmy 2 metody: up() oraz down() – odpowiedzialne odpowiednio za przenoszenie artykułu w górę lub w dół.

public function up()
{
  $item = $this->getInvoker();
  $q = $item->getTable()->createQuery('u')
     ->where('u.my_order >; ?', $item->my_order)
     ->orderBy('u.my_order asc')
     ->fetchOne();
  if ($q)
  {
    $item->swapOrder($q);
  }
}

public function down()
{
  $item = $this->getInvoker();
  $q = $item->getTable()->createQuery('u')
     ->where('u.my_order < ?', $item->my_order)
     ->orderBy('u.my_order desc')
     ->fetchOne();
  if ($q)
  {
    $item->;swapOrder($q);
  }
}

Dodajemy także metodę swapOrder():

public function swapOrder($item2)
{
  $item1 = $this->getInvoker();
  $tmpmy_order = $item1->my_order;
  $item1->my_order = $item2->my_order;
  $item2->my_order = $tmpmy_order;
  $item1->save();
  $item2->save();
}

Warto zauważyć, że nie używamy tutaj nigdzie $this w kontekście obiektu, na którym operujemy. Zamiast tego używamy:

$invoker = $this->getInvoker();

a następnie na $invoker operujemy tak, jakby to był nasz obiekt.

Aby nasze sortowanie działało, musimy teraz zadbać o to, aby wszystkie zapytania odwołujące się do naszej tabeli je uwzględniały. Możemy oczywiście zmodyfikować każde zapytanie ręcznie, ale przecież mamy od tego ORM’a. Wystarczy tylko włączyć tzw. preDQL. Dzięki preDQL możemy wpiąć się do każdego zapytania korzystającego z naszej tabeli i je zmodyfikować.

Najpierw musimy stworzyć nasze zapytanie. W tym celu musimy stworzyć klasę, która będzie nasłuchiwać na zdarzenia select:

lib/behavior/SortableListener.class.php

class SortableListener extends Doctrine_Record_Listener
{
  public function preDqlSelect(Doctrine_Event $event)
  {
    $q = $event->getQuery();
    $params = $event->getParams();
    $q->orderBy($params['alias'] . '.my_order desc');
  }
}

Metoda preDqlSelect zostanie wywołana zawsze, gdy będziemy tworzyć zapytanie na naszej tabeli.
Co ona robi? Do zmiennej $q pobieramy zapytanie. Do $params pobieramy parametry, aby wiedzieć, jaki był alias w zapytaniu. Jako ciekawostkę podam, że użycie $q->getRootAlias() spowoduje wystąpienie błędu ‚Duplicate alias’.

Po pobraniu obu obiektów najzwyczajniej w świecie zmieniamy zapytanie dodając orderBy.

Żeby nasze zapytanie się zmieniało, musimy wpiąć naszego preDqlSelect. Aby to zrobić, musimy zmodyfikować 2 pliki:

config/ProjectConfiguration.class.php

public function configureDoctrine(Doctrine_Manager $manager)
{
  $manager->setAttribute(Doctrine_Core::ATTR_USE_DQL_CALLBACKS, true);
}

oraz:
lib/behavior/Sortable.class.php

public function setTableDefinition()
{
  $this->hasColumn('my_order', 'integer');
  $this->addListener(new SortableListener());
}

Tutaj dodaliśmy linijkę $this->addListener(..)

Została (prawie że) ostatnia rzecz: nowo dodane wpisy do bazy muszą mieć ustawioną jakąś kolejność – wartość my_order musi się nadawać z automatu. Pierwszym rozwiązaniem byłoby nadpisanie metody save, ale przecież mamy behaviora. Możemy dorzucić funkcję preSave, która zostanie wywołana przed save i sprawdzi, czy mamy ustawioną wartośc dla my_order. Jeżeli ta wartość nie będzie ustawiona, to ją ustawimy.

lib/behavior/SortableListener.class.php

public function preSave(Doctrine_Event $event)
{
  $invoker = $event->getInvoker();
  if ('' == $invoker->my_order)
  {
    $q = $invoker->getTable()->createQuery('c')->orderBy('c.my_order desc')->fetchOne();
    if (!$q)
    {
      $invoker->my_order = 1;
    }
    else
    {
      $invoker->my_order = $q->my_order+1;
    }
  }
}

Ok, dodajmy do bazy wpisy:

public function executeIndex(sfWebRequest $request)
{
  for ($i = 0; $i<10; $i++) { $article = new Article(); $article->save();
  }
}

Gdy popatrzymy na bazę danych, to zobaczymy 10 wpisów posiadających kolejne wartości my_order. Wszystko teraz dzieję się automatycznie. Spróbujmy teraz wykonać następujący kod:

public function executeIndex(sfWebRequest $request)
{
  $article = Doctrine::getTable('Article')->findOneById(3);
  $article->down();
}

Gdy zajrzymy do bazy, to ujrzymy, że wpis o id=3 ma my_order=2, a wpis o id=2 ma my_order=3. Jak widać, nasz behavior działa :)

Niestety, w drugą stronę on nie zadziała – jak wywołamy up nie zobaczymy tego, co oczekujemy. Powód? Otóż sami na siebie przygotowaliśmy pułapkę. Otóż dodaliśmy listenera, który każde zapytanie zmieni w ten sposób, że będzie sortować zapytanie po my_order malejąco. Zatem budowa zapytania w metodzie up(), które ma sortować po my_order rosnąco nie zda egzaminu, gdyż na koniec zostanie to nadpisane. Metoda down sortuje malejąco i dlatego działa.

Rozwiązanie? Cóż, jest kilka możliwości. Ja probponuję dodać do listenera zmienną konfiguracyjną (np. app_sort_enable), którą będziemy sprawdzać. Daje to nam kontrolę globalną (możemy w app.yml zdefiniować, czy chcemy sortować), a dodatkowo możemy wyłączać sortowanie dynamicznie za pomocą klasy statycznej sfConfig.

Zatem, do dzieła! (będą to już ostatnie zmiany)

W tym pliku dodajemy sprawdzanie app_sort_enable (domyślnie true):
lib/behavior/SortableListener.class.php

class SortableListener extends Doctrine_Record_Listener
{
  public function preDqlSelect(Doctrine_Event $event)
  {
    if (sfConfig::get('app_sort_enable', true))
    {
      $q = $event->getQuery();
      $params = $event->getParams();
      $q->orderBy($params['alias'] . '.my_order desc');
    }
  }
}

A tutaj zapamiętujemy stare ustawienie zmiennej jako $old, a potem ustawiamy app_sort_enable na false. Na sam koniec przywracamy starą wartość:
lib/behavior/Sortable.class.php

public function up()
{
  $old = sfConfig::get('app_sort_enable', true);
  sfConfig::set('app_sort_enable', false);
  $item = $this->getInvoker();
  $q = $item->getTable()->createQuery('u')
     ->where('u.my_order > ?', $item->my_order)
     ->orderBy('u.my_order asc')
     ->fetchOne();
  if ($q)
  {
    $item->swapOrder($q);
  }
  sfConfig::set('app_sort_enable', $old);
}

Tym razem to definitywny koniec – stworzyliśmy naszego behaviora! Co dzięki temu uzyskaliśmy?

  • Teraz możemy zapisywać sobie obiekty klasy Article, a będzie nam się sam ustawiał my_order (w momencie, gdy go nie ustawimy to będzie max+1).
  • Mamy dodatkowo dostęp do metody up() oraz down(), które „przesuwają” nasze obiekty w górę lub w dół
    Każde zapytanie będzie sortowane po my_order – nie musimy o tym pamiętać – zrobi się to samo.
  • Teraz możemy bardzo łatwo dodać sortowanie do naszych kategorii (pracowaliśmy na article) – wystarczy tylko w schema.yml dodać linijkę actAs: [Sortable].

Na sam koniec listing plików w formie ostatecznej:

lib/behavior/SortableListener.class.php

class Sortable extends Doctrine_Template
{
  protected $_options = array();

  public function __construct($options)
  {
    $this->_options = $options;
  }

  public function setTableDefinition()
  {
    $this->hasColumn('my_order', 'integer');
    $this->addListener(new SortableListener());
  }

  public function up()
  {
    $old = sfConfig::get('app_sort_enable', true);
    sfConfig::set('app_sort_enable', false);
    $item = $this->getInvoker();
    $q = $item->getTable()->createQuery('u')
       ->where('u.my_order > ?', $item->my_order)
       ->orderBy('u.my_order asc')
       ->fetchOne();
    if ($q)
    {
      $item->swapOrder($q);
    }
    sfConfig::set('app_sort_enable', $old);
  }

  public function down()
  {
    $item = $this->getInvoker();
    $q = $item->getTable()->createQuery('u')
       ->where('u.my_order < ?', $item->my_order)
       ->orderBy('u.my_order desc')
       ->fetchOne();
    if ($q)
    {
      $item->swapOrder($q);
    }
  }

  public function swapOrder($item2)
  {
    $item1 = $this->getInvoker();
    $tmpmy_order = $item1->my_order;
    $item1->my_order = $item2->my_order;
    $item2->my_order = $tmpmy_order;
    $item1->save();
    $item2->save();
  }
}

lib/behavior/SortableListener.class.php

class SortableListener extends Doctrine_Record_Listener
{
  public function preDqlSelect(Doctrine_Event $event)
  {
    if (sfConfig::get('app_sort_enable', true))
    {
      $q = $event->getQuery();
      $params = $event->getParams();
      $q->orderBy($params['alias'] . '.my_order desc');
    }
  }

  public function preSave(Doctrine_Event $event)
  {
    $invoker = $event->getInvoker();
    if ('' == $invoker->my_order)
    {
      $q = $invoker->getTable()->createQuery('c')->orderBy('c.my_order desc')->fetchOne();
      if (!$q)
      {
        $invoker->my_order = 1;
      }
      else
      {
        $invoker->my_order = $q->my_order+1;
      }
    }
  }
}
config/doctrine/schema.yml
article:
  actAs: [Sortable]
  columns:
    title:       { type: string(255) }
    content:     { type: string }
    category_id: { type: integer }
  relations:
    category:
      local: category_id

category:
  columns:
    name:        { type: string(255) }

config/ProjectConfiguration.class.php

require_once dirname(__FILE__).'/../lib/vendor/symfony/lib/autoload/sfCoreAutoload.class.php';
sfCoreAutoload::register();

class ProjectConfiguration extends sfProjectConfiguration
{
  public function setup()
  {
    $this->enablePlugins('sfDoctrinePlugin');
  }

  public function configureDoctrine(Doctrine_Manager $manager)
  {
    $manager->setAttribute(Doctrine_Core::ATTR_USE_DQL_CALLBACKS, true);
  }
}