//DEVGURU

Archives: August, 2008

Named scope w CakePHP

Sunday, August 31st, 2008

Kilka dni temu napisałem jak ulepszyć system callbacków w kontrolerach CakePHP wzorując się na rozwiązaniu zastosowanym w Ruby on Rails. Dziś przeniesiemy kolejny fajny feature z Railsów do Cake’a – named scope.

Named scope czyli po polsku “nazwany zakres/dziedzina”. Chodzi o definicję podzbiorów rekordów danego modelu w celu późniejszego łatwiejszego ich wyszukiwania.

Zaczniemy znowu od przykładu. Definicja modelu User w RoR:

class User < ActiveRecord::Base
  # podzbiór użytkowników, którzy aktywowali konto
  named_scope :activated, :conditions => "activated_at is not null" 

  # podzbiór użytkoników, którzy są on-line
  named_scope :online, :conditions => "date_add(last_activity, interval 5 minute) > now()"
end

To bardzo prosty przykład, w rzeczywistości named scope nadaje się też do dużo bardziej ciekawych rzeczy. Jednak “wersja Cake’owa”, którą zaprezentuje poniżej jest właśnie ograniczona do definicji warunków.

Zdefiniowane wyżej zakresy możemy później użyć podczas wyszukiwania:

# wszyscy aktywowani użytkownicy
users = User.activated.find(:all) 

# wszyscy aktywowani i on-line użytkownicy z liczbą punktów większą od 10
users = User.activated.online.find(:all, :conditions => "points > 10")

Zalety zapisu widać właściwie od razu. Named scope upraszcza zapis. Często powtarzające się warunki wyszukiwania (dostęp tylko do aktywowanych użytkowników potrzebujemy w wielu miejscach aplikacji) umieszczamy w named scope, a inne umieszczamy “po staremu” w :conditions. Zapis wygodny i bardzo czytelny.

Podobną funkcjonalność w Cake’u zapewni nam NamedScopeBehavior, który przetłumaczy (w callbacku modelu beforeFind) zdefiniowane zakresy na warunki przekazane później do zapytania SQL.

Plik named_scope.php pobrany repozytorium należy umieścić w folderze app/models/behaviors. Później w modelu możemy już definiować zakresy.

class User extends AppModel {
  var $actsAs = array(
    'NamedScope' => array(
      'activated' => array('User.activated in not null'),
      'online' => array('date_add(User.last_activity, interval 5 minute) > now()')
    )
  );
}

Teraz już możemy zacząć korzystać.

$this->User->find('all', array('scope' => 'activated'));
$this->User->find('all',
  array('conditions' => 'points > 10', 'scope' => array('activated', 'online')));

Paginacja działa równie dobrze:

$paginate = array(
  'User' => array(
    'order' => 'created ASC',
    'limit' => 20,
    'scope' => array('online', 'activated')
  )
);

Mam nadzieję, że NamedScopeBehavior ułatwi i uprzyjemni Wam trochę development w CakePHP :)

Ulepszony system callback’ów w kontrolerach CakePHP

Friday, August 22nd, 2008

Callbacki w kontrolerach (beforeFilter, beforeRender lub afterFilter) to bardzo użyteczne narzędzia, pozwalają zaoszczędzić sporo czasu, czynią kod bardziej czytelnym i bardziej DRY. Jednym słowem miód… chyba, że zrobią się zbyt duże.

Sprawa najczęściej wygląda tak:

  • chcemy, żeby jakiś kod wykonał się przed każdym requestem w kontrolerze, więc wrzucamy go do beforeFilter
  • z kolei inne rzeczy mają się wykonywać tylko przed niektórymi akcjami, więc je też wrzucamy do beforeFilter dodając jeszcze blok if-then-else, który zdecyduje kiedy je wykonać
  • mamy jeszcze trochę kodu, który chcemy wykonać przed requestem, ale tylko gdy użytkownik jest zalogowany, kolejny if-then-else ląduje w beforeFilter
  • i tak dalej, aż przestajemy nad wszystkim panować

Można by zrobić to lepiej? No cóż, spójrzmy na poniższy kawałek kodu z Ruby on Rails.

class UsersController < ApplicationController
  before_filter :do_something, :do_always
  before_filter :do_something_else, :only => [:show, :new]
  before_filter :do_something_different, :except => :edit

  # method definitions commented out
end

Oto co tu się dzieje:

  • metody do_something i do_always zostaną wykonane przed każdym requestem
  • do_something_else tylko przed akcjami show i new
  • do_something_different przed wszystkimi akcjami oprócz edit

Prosto i czysto. Każdy kawałek kodu, który chcemy wykonać w before_filter (niezależnie od warunków) ląduje w osobnej metodzie kontrolera. Kontroler sam decyduje co i kiedy wykonać.

Jest to łatwiejsze w zarządzaniu i testowaniu niż podejście zastosowane w CakePHP. Więc… przenieśmy je do Cake’a.

Kod AppControllera do pobrania z pastie.

Teraz możemy już robić takie rzeczy:

  var $beforeFilter = array(
    array(
      'methods' => array('do_something', 'do_always')
    ),
    array(
      'methods' => array('do_something_else'),
      'only' => array('show', 'new')
    ),
    array(
      'methods' => array('do_something_different'),
      'except' => array('edit')
    )
  );

  function _do_something() { }
  function _do_always() { }
  function _do_something_else() { }
  function _do_something_different { }

Powyższy kod jest ekwiwalentem kodu kontrolera w Ruby on Rails z początku postu. Znaki podkreślenia przed nazwami metod pozwalają je odróżnić od regularnych akcji zdefiniowanych w kontrolerze.

Dodatkowo możemy także określić inne warunki wykonania metod przez callback.

  var $beforeFilter = array(
    array(
      'methods' => array('do_something'),
      'if' => array('is_admin')
    ),
    array(
      'methods' => array('do_something_else'),
      'unless' => array('is_logged_in')
    )
  );

  function _logged_in() { }
  function _is_admin() { }

Teraz metoda do_something zostanie wywołana tylko gdy _is_admin zwróci true, a do_something_else gdy _is_logged_in zwróci false. Zresztą kod mówi sam za siebie.

Oczywiście wszystkie opcje (only, except, if, unless) mogą być używane razem lub w dowolnych konfiuracjach.

Definicje w tabeli $beforeFilter (lub $beforeRender, lub $afterFilter) będą analizowane w kolejności w jakiej się się w niej pojawią (o ile oczywiście ich warunki zostaną spełnione).

Dodatkowo dzięki rozszerzeniu metody __mergeVars, która jest automatycznie wywoływana przy inicjalizacji kontrolera, możliwe jest definiowanie tablic również w AppControllerze i PluginAppControllerze. Wtedy pierwszeństwo mają te z AppControllera, następnie PluginAppControllera i na końcu ze zwykłego kontrolera.

Enjoy.

Test-driven development w RoR – wprowadzenie

Monday, August 18th, 2008

Zacznę może od oczywistości doskonale znanej wszystkim, którzy mieli okazję pracować nad projektem większym, niż “blog w 15 minut”: wraz z rozwojem aplikacji rośnie liczba błędów, które wkradają się niepostrzeżenie “gdzieś, kiedyś”. Drobna zmiana jednej funkcjonalności potrafi wywalić połowę aplikacji – a to niezalogowany użytkownik dostaje w twarz wyjątek (bo nam, zalogowanym na developmencie przecież działa!), a to mail się nie wyśle, bo nie lubi użytkownika nil. Częstą praktyką jest zostawianie wyławiania takich “smaczków” na moment “tydzień-przed-wdrożeniem”. Często też “tydzień-przed-wdrożeniem” okazuje się być “potrzebujemy-jeszcze-pięciu-kolejnych-dni”, “trzeba-to-przepisać-bo-nie-działa” czy tym podobnymi… Na szczęście można temu zaradzić. Panie i panowie (i programiści), kłania się przed wami Test-driven development.

Zamysł jest taki: zanim napiszemy faktyczny kod – piszemy krótkie “przypadki testowe” (z ang. test cases). Przykłady?

  • zanim napiszemy funkcję do autentykacji użytkownika – piszemy test case, który sprawdza, czy logowanie przykładowego użytkownika powiodło się
  • zanim napiszemy faktyczne powiązania pomiędzy modelami – opisujemy je w testach
  • zanim napiszemy walidację konkretnego modelu – rozpisujemy komplet przypadków testowych, który atakuje model poprawnymi (lub niepoprawnymi) danymi i sprawdzamy, czy się zapisuje (lub też nie)

Po napisaniu test case zabieramy się za pisanie właściwej funkcjonalności, odpalamy testy i cieszymy się, jeśli wszystkie przeszły – a jeśli nie, to poprawiamy kod. Następnie piszemy kolejny test. Piszemy funkcjonalność. Odpalamy testy…
“Po co mam pisać dwa razy to samo?”, “czy to się nie kłóci z agile software development?”. Jeśli nie zadaliście sobie tych pytań, to szkoda. Ja zadałem. Zyskujemy kilka rzeczy:

  • Kompletny zestaw testów. Pozwala mocno zaoszczędzić czas (nawet, jeśli z początku wydaje się być inaczej) – poza wspomnianym już problemem debugowania przed wdrożeniem, testy dają nam również pewność, że aplikacja działa w różnych środowiskach (development, staging, production…), które nie zawsze przecież są na tych samych maszynach. Do tego jesteśmy świadomi błędów gdy zmiana jednej funkcjonalności, od której zależne są inne, spowoduje wysypanie się któregoś miejsca aplikacji, nawet bardzo ukrytego (jeśli jest pokryte testami, oczywiście).
  • Klarowne spojrzenie na cały projekt. Możemy bardziej skupić się na tym, jak aplikacja powinna działać w środku – pisząc test nie obchodzi nas, jak będzie wyglądała funkcja logująca użytkownika – obchodzi nas za to, w jaki sposób może zalogować się użytkownik. (Prosta myśl, ale trudna do przekazania).

Ok, uwierzcie mi: testy są przydatne. Do tego pisanie testów może być przyjemne i zautomatyzowane. Pisanie testów może uratować życia. Od czego zacząć?

Na start polecam shoulda plugin. Załóżmy, że chcemy mieć dzieci… Co powinno mieć dziecko? Imię, wagę, wzrost. Generujemy model za pomocą script/generate. Dostajemy model w /app/models i test w /test/units. Zacznijmy od testu.


require File.dirname(__FILE__) + '/../test_helper'

class ChildTest < ActiveSupport::TestCase
  should_have_db_columns :name, :weight, :height, :gender, :father_id
end

Następnie tworzymy migrację. Uwzględniamy w niej wymagane pola. Odpalamy rake db:migrate. Odpalamy test: rake test:units. Cieszymy się.

Dodajmy przykładowe dziecko do testów. Do pliku /test/fixtures/children.yml wrzućmy Timmy’ego:


timmy:
  id: 1
  name: Timmy
  height: 103
  weight: 15
  father_id: 1

Sporo waży. Dziecko powinno też mieć ojca:


require File.dirname(__FILE__) + '/../test_helper'

class ChildTest < ActiveSupport::TestCase
  should_have_db_columns :name, :weight, :height, :gender, :father_id

  should_belong_to :father

end

W modelu dodajemy odpowiednią relację:


belongs_to :father

Odpalamy testy. Cieszymy się. Co jednak, gdy dziecko nie ma ojca? Jest sierotą. Sprawdzimy to. Najpierw testem:


require File.dirname(__FILE__) + '/../test_helper'

class ChildTest < ActiveSupport::TestCase
  should_have_db_columns :name, :weight, :height, :gender, :father_id
  should_require_attributes :name

  should_belong_to :father

  context "A child" do
    setup do
      @child = children(:timmy)
    end
    should "be an orphan when father_id is null" do
      @child.father_id = nil
      assert_equal true, @child.orphan?
    end
  end
end

Następnie metoda w modelu:


def orphan?
  return self.father_id.nil?
end

Odpalamy test. Przechodzi. Dziecko jest sierotą. Możemy napisać kolejny test sprawdzający, czy dziecko mające ojca nie jest sierotą. Możemy sprawdzić, czy ojciec ma więcej dzieci i w tym przypadku sprawdzić, czy jest jedynakiem. Możemy sprawdzić walidację dziecka bez wagi (wiadomo, że takie nie istnieją)…

W następnym poście opiszę narzędzia, które pozwalają na zautomatyzowanie testowania czy też sprawdzanie procentowego pokrycia kodu testami – a póki co polecam przejrzeć dokumentację rdocs pluginu shoulda. To najlepsze źródło do zrozumienia idei TDD w railsach, na jakie trafiłem.