Lazy Loading in Model layer (cakePHP style)

CakePHP is not supporting Lazy Loading yet. Before if starts, I asked myself “how would I do that?”. My solution is based on PHP5, so I imagine that supporting PHP4 in 1.x branch is the reason of no-Lazy-Loading. Anyway – I wanted to share my ideas.

Let’s start with basics. The opposite of lazy loading is eager loading, which means “Load anything that can be needed. Ever.” In that approach model layer in Cake could look like that (it’s very simplified, the only relationship is belongsTo):

Eager Loading


class Model {
	function find(){

		$r = new ReflectionClass($this);
		print "calling ".$r->getName()."::find($conditions)";

		$query = "Select * from `".strtolower($r->getName())."s` ".
					"as `".$r->getName()."`";

		var_dump($query);

		//return query result
	}

	function __construct(){
		$r = new ReflectionClass($this);
		print("creating ".$r->getName()."
"); if(isset($this->belongsTo) && is_array($this->belongsTo)){ foreach($this->belongsTo as $related){ $this->{$related} = new $related(); } } } } class User extends Model{ protected $belongsTo = array("Group"); } class Group extends Model{ } $user = new User(); $user->find(); echo "
"; $user->Group->find();

One could see that Group object was initialized long before it is used:

creating User
creating Group
calling User::find()

string 'Select * from `users` as `User`' (length=31)


calling Group::find()

string 'Select * from `groups` as `Group`' (length=33)

Even more – it’s being initialized even if we don’t call any method in this object (comment $user->Group->find(); to see that).

Lazy Loading

class Model {
	function find(){

		$r = new ReflectionClass($this);
		print "calling ".$r->getName()."::find($conditions)";

		$query = "Select * from `".strtolower($r->getName())."s` ".
					"as `".$r->getName()."`";

		var_dump($query);

		//return query result
	}

	function __construct(){
		$r = new ReflectionClass($this);
		print("creating ".$r->getName()."
"); } function __get($property){ if(!isset($this->{$property})) { $this->{$property} = new $property(); } if(in_array($property, $this->belongsTo)){ return $this->{$property}; }else{ $r = new ReflectionClass($this); throw new Exception( "$property not related with {$r->getName()}" ); } } } class User extends Model{ protected $belongsTo = array("Group"); } class Group extends Model{ } $user = new User(); $user->find(); echo "
"; $user->Group->find();

When using Lazy Loading pattern, Group object isn’t created untill we need it. Magic method __get() is used to make that possible. __get() is called whenever we try to access an attribute in object that doesn’t exists.

But there’s question of searching in model, so it returns appropriate data, ie. var_dump($user->find());

array
  0 => 
    array
      'User' => 
        array
          'id' => int 1
          'name' => string 'Frank' (length=6)
      'Group' => 
        array
          'id' => int 1
          'name' => string 'Admin' (length=5)
  1 => 
    array
      'User' => 
        array
          'id' => int 2
          'name' => string 'John' (length=5)
      'Group' => 
        array
          'id' => int 10
          'name' => string 'User' (length=4)

We know that belongsTo means that User should have field qroup_id, so the query could look like this:

Select User.id, User.name, Group.id, Group.name from users as User
Inner Join groups as Group on (User.group_id = Group.id)

(Now CakePHP, since 1.2 AFAIR, has Containable behavior, but it depends on loaded models).

Lets keep cake’s convention and assume, that we’ll pass informations about related elements through $contain variable.

function find($contain=null)

Now I want to say: object $user, please return me all users from database and groups they’re in:

$user =  new User();

$user->find(
	array(
		"belongsTo" => array($user->Group)
	)
);

Lets update find() method:

	function find($contain=null){

		$r = new ReflectionClass($this);
		print "calling ".$r->getName()."::find($conditions)";

		$query = "Select * from `".strtolower($r->getName())."s` ".
					"as `".$r->getName()."` \n";


		if(!empty($contain) && is_array($contain["belongsTo"])){
			foreach($contain["belongsTo"] as $belongsToModel){
				$query .= $belongsToModel->getBelongsToJoin($this);
			}
		}


		var_dump($query);

		//return query result
	}

which now assumes that model has method, which can create the join part of query:

	function getBelongsToJoin($obj){
		$owner_reflection = new ReflectionObject($this);
		$obj_reflection = new ReflectionObject($obj);
		return "INNER JOIN `".strtolower($owner_reflection->getName())."s` 
					as `".$owner_reflection->getName()."`
						ON (`".$owner_reflection->getName()."`.`id` = `".$obj_reflection->getName()."`.`".strtolower($owner_reflection->getName())."_id`)
					";
	}

it is ugly because we keep using reflections on and on. To make that prettier we could implements getName() method in Model class.

Anyway. Even in case of find which considers relations, we can use Lazy Loading pattern.

As a bonus, code sample with more related models and example of use (and small refactorin – when I write about Lazy Loading it looked silly when I was loading ReflectionClasss fifteen times ;)):


class ModelException extends Exception{}

class Model {

	protected $reflection = null;

	function getName(){
		if(is_null($this->reflection)){
			$this->reflection= new ReflectionClass($this);
		}
		return $this->reflection->getName();
	}

	function getTableName(){
		return strtolower( $this->getName() . "s" );
	}

	function getFKName(){
		return strtolower( $this->getName() . "_id");
	}

	function getBelongsToJoin($obj){
		$owner_reflection = new ReflectionObject($this);
		$obj_reflection = new ReflectionObject($obj);
		return "INNER JOIN `".$this->getTableName()."` 
					as `".$this->getName()."`
						ON (`".$this->getName()."`.`id` = `".$obj->getName()."`.`".$this->getFKName()."`)
					";
	}

	function find($contain=null){

		$r = new ReflectionClass($this);
		print "calling ".$r->getName()."::find($conditions)";

		$query = "\nSelect * from `".strtolower($r->getName())."s` ".
					"as `".$r->getName()."` \n";


		if(!empty($contain) && is_array($contain["belongsTo"])){
			foreach($contain["belongsTo"] as $belongsToModel){
				$query .= $belongsToModel->getBelongsToJoin($this);
			}
		}


		var_dump($query);

		//return query result
	}

	function __construct(){
		$r = new ReflectionClass($this);
		print("creating ".$r->getName()."
"); } function __get($property){ if(!isset($this->{$property})) { $this->{$property} = new $property(); } if(in_array($property, $this->belongsTo)){ return $this->{$property}; }else{ $r = new ReflectionClass($this); throw new ModelException( "$property not related with {$r->getName()}" ); } } } class User extends Model{ protected $belongsTo = array("Group", "Unit", "Company"); } class Group extends Model{ } class Unit extends Model{} class Company extends Model{} $user = new User(); $user->find( array( "belongsTo" => array($user->Unit, $user->Company) ) );

What You think about that?

Share Button

Lazy Loading w modelach a’la CakePHP

CakePHP nie wspiera aktualnie leniwego ładowania obiektów. W prawdzie są jakieś skrypty, ale jeszcze żaden z nich nie chciał mi zadziałać. Zacząłem się zastanawiać: “Gdybym miał zaimplementować Lazy Loading w warstwie modelu, to jak bym to zrobił?” To co mi wyszło korzysta z rozwiązań PHP5, więc może wsparcie gałęzi 1.x Cake’a dla PHP4 implikuje brak tego wzorca?

Tak czy siak, podzielę się z Wami moimi pomysłami.

Zacznijmy od początków. Przeciwieństwem leniwego ładowania jest ładowanie gorliwe (lub zachłanne), to znaczy ładowanie na zapas wszystkiego co może okazać się potrzebne. Warstwa modelu w cake’u mogła by wyglądać tak (duże uproszeczenie, jedyna możliwa relacja to belongsTo).

Eager Loading


class Model {
	function find(){

		$r = new ReflectionClass($this);
		print "calling ".$r->getName()."::find($conditions)";

		$query = "Select * from `".strtolower($r->getName())."s` ".
					"as `".$r->getName()."`";

		var_dump($query);

		//return query result
	}

	function __construct(){
		$r = new ReflectionClass($this);
		print("creating ".$r->getName()."
"); if(isset($this->belongsTo) && is_array($this->belongsTo)){ foreach($this->belongsTo as $related){ $this->{$related} = new $related(); } } } } class User extends Model{ protected $belongsTo = array("Group"); } class Group extends Model{ } $user = new User(); $user->find(); echo "
"; $user->Group->find();

Widzimy, że obiekt Group jest inicjowany długo przed użyciem:

creating User
creating Group
calling User::find()

string 'Select * from `users` as `User`' (length=31)


calling Group::find()

string 'Select * from `groups` as `Group`' (length=33)

Mało tego, ładuje się nawet jeśli nigdy nie wywołamy metody w tym obiekcie (zakomentuj $user->Group->find(); żeby się o tym przekonać).

Lazy Loading

class Model {
	function find(){

		$r = new ReflectionClass($this);
		print "calling ".$r->getName()."::find($conditions)";

		$query = "Select * from `".strtolower($r->getName())."s` ".
					"as `".$r->getName()."`";

		var_dump($query);

		//return query result
	}

	function __construct(){
		$r = new ReflectionClass($this);
		print("creating ".$r->getName()."
"); } function __get($property){ if(!isset($this->{$property})) { $this->{$property} = new $property(); } if(in_array($property, $this->belongsTo)){ return $this->{$property}; }else{ $r = new ReflectionClass($this); throw new Exception( "$property not related with {$r->getName()}" ); } } } class User extends Model{ protected $belongsTo = array("Group"); } class Group extends Model{ } $user = new User(); $user->find(); echo "
"; $user->Group->find();

W tym rozwiązaniu obiekt Group nie zostanie stworzony, dopóki nie będziemy go potrzebować. Wykorzystana jest do tego magiczna metoda __get, która jest wywoływana wtedy, gdy próbujemy się odnieść do atrybutu obiektu, który nie istnieje.

ale pozostaje kwestia wyszukiwania w modelu tak, żeby zwrócił powiązane dane
np.: var_dump($user->find());

array
  0 => 
    array
      'User' => 
        array
          'id' => int 1
          'name' => string 'Franek' (length=6)
      'Group' => 
        array
          'id' => int 1
          'name' => string 'Admin' (length=5)
  1 => 
    array
      'User' => 
        array
          'id' => int 2
          'name' => string 'Janek' (length=5)
      'Group' => 
        array
          'id' => int 10
          'name' => string 'User' (length=4)

Wiemy, że belongsTo oznacza, że User powinien mieć pole (group_id), zatem query powinno wyglądać mniej więcej tak:

Select User.id, User.name, Group.id, Group.name from users as User
Inner Join groups as Group on (User.group_id = Group.id)

W tej chwili cakephp zawiera behavior containable, jednak on polega na załadowanych już modelach.

Zostańmy przy terminologii cake’a i przyjmijmy, że będziemy przekazywać informacje na temat powiązanych elementów w zmiennej $contain

function find($contain=null)

Teraz chciałbym powiedzieć: obiekcie $user zwróć mi wszystkich użytkowników z bazy wraz z informacją w jakich są grupach:

$user =  new User();

$user->find(
	array(
		"belongsTo" => array($user->Group)
	)
);

Zatem zaktualizuję metodę find:

	function find($contain=null){

		$r = new ReflectionClass($this);
		print "calling ".$r->getName()."::find($conditions)";

		$query = "Select * from `".strtolower($r->getName())."s` ".
					"as `".$r->getName()."` \n";


		if(!empty($contain) && is_array($contain["belongsTo"])){
			foreach($contain["belongsTo"] as $belongsToModel){
				$query .= $belongsToModel->getBelongsToJoin($this);
			}
		}


		var_dump($query);

		//return query result
	}

która od teraz zakłada, że model posiada metodę, która potrafi utworzyć odpowiedniego joina:

	function getBelongsToJoin($obj){
		$owner_reflection = new ReflectionObject($this);
		$obj_reflection = new ReflectionObject($obj);
		return "INNER JOIN `".strtolower($owner_reflection->getName())."s` 
					as `".$owner_reflection->getName()."`
						ON (`".$owner_reflection->getName()."`.`id` = `".$obj_reflection->getName()."`.`".strtolower($owner_reflection->getName())."_id`)
					";
	}

to jest oczywiście bardzo brzydkie ze względu na refleksje. Metodę getName() można jednak zaimplementować w klasie Model.

Tak czy siak, w tym wypadku nawet w find()’zie uwzględniającym relacje można stosować Lazy Loading

Na koniec całość kodu, z większą ilością relacji i przykładem działania (i małym refactoringiem – skoro już mowa o lazy loading to głupio było ładować piętnaście razy ReflectionClass):


class ModelException extends Exception{}

class Model {

	protected $reflection = null;

	function getName(){
		if(is_null($this->reflection)){
			$this->reflection= new ReflectionClass($this);
		}
		return $this->reflection->getName();
	}

	function getTableName(){
		return strtolower( $this->getName() . "s" );
	}

	function getFKName(){
		return strtolower( $this->getName() . "_id");
	}

	function getBelongsToJoin($obj){
		$owner_reflection = new ReflectionObject($this);
		$obj_reflection = new ReflectionObject($obj);
		return "INNER JOIN `".$this->getTableName()."` 
					as `".$this->getName()."`
						ON (`".$this->getName()."`.`id` = `".$obj->getName()."`.`".$this->getFKName()."`)
					";
	}

	function find($contain=null){

		$r = new ReflectionClass($this);
		print "calling ".$r->getName()."::find($conditions)";

		$query = "\nSelect * from `".strtolower($r->getName())."s` ".
					"as `".$r->getName()."` \n";


		if(!empty($contain) && is_array($contain["belongsTo"])){
			foreach($contain["belongsTo"] as $belongsToModel){
				$query .= $belongsToModel->getBelongsToJoin($this);
			}
		}


		var_dump($query);

		//return query result
	}

	function __construct(){
		$r = new ReflectionClass($this);
		print("creating ".$r->getName()."
"); } function __get($property){ if(!isset($this->{$property})) { $this->{$property} = new $property(); } if(in_array($property, $this->belongsTo)){ return $this->{$property}; }else{ $r = new ReflectionClass($this); throw new ModelException( "$property not related with {$r->getName()}" ); } } } class User extends Model{ protected $belongsTo = array("Group", "Unit", "Company"); } class Group extends Model{ } class Unit extends Model{} class Company extends Model{} $user = new User(); $user->find( array( "belongsTo" => array($user->Unit, $user->Company) ) );
Share Button

Fat model, skinny controller – przykład

Niedawno przy projekcie trafiliśmy na ciekawy problem. Chciałbym się podzielić z Wami tym czego się nauczyliśmy.

Problem przedstawię w bardzo uproszczonej formie, bo trudno bez machania rękoma przy zabazgranej tablicy wyjaśnić go w całości.

Problem

Mamy formularz dodawania kosztów, który jest dość specyficzny. Można dodać “łysy koszt” i wtedy pojawia się formularz z wyborem firmy i innymi atrybutami. Można jednak z widoku konkretnej firmy wybrać opcję “dodaj koszt” – w takim wypadku lista wyboru firm się nie pojawia.

Dość naturalne jest załatwić to kontrolerem. Są różne możliwości, jedną z nich jest takie zdefiniowanie akcji:

function add($entryId=null) {
   if(!empty($this->data)){
       $this->Cost->save($this->data);
       // redirect w jakieś przyjemne okolice
   }
   if(is_null($entryId)){
      $this->set("entry", $this->Cost->Entry->read(null, $entryId);
   }else{
      $this->set("entries", $this->Cost->Entry->find("list");
   }
}

W widoku wiadomo – jeśli jest $entry, wyświetlimy $entry[“Entry”][“name”] i w jakimś ukrytym inpucie wrzucimy id, jeśli istnieje $entries – wyświetlimy selecta.

Na razie w kontrolerze wygląda to wystarczająco zgrabnie, żeby tego nie ruszać. Ale jeśli znajdziesz się w sytuacji, gdy ilość przypadków jest coraz większa – możesz się zorientować, że dodajesz kolejne argumenty do metody add i rozbudowujesz tą metodę. W takiej sytuacji może Ci pomóc takie podejście.

Propozycja rozwiązania

Po pierwsze olej zwykłe parametry funkcji, a zacznij używać parametrów “named”. Czyli zamiast:
costs/add/1/3/45/23
costs/add/2///22
będziesz miał
costs/add/cost_id:1/entry_id:3/param3:45/param4:23
costs/add/cost_id:2/param4:22

Po drugie – przenieś logikę działania do modelu. np:

//model Cost
function giveMeProperData($params){
   if(isset($params["cost_id")){
      // do sth
      return $some_array;
   }
   if(isset($params["entry_id")){
      // do sth
      return $other_array;
   }
   return array();
}

Kontroler uprość do granic możliwości:

function add($entryId=null) {
   if(!empty($this->data)){
       $this->Cost->save($this->data);
       // redirect w jakieś przyjemne okolice
   }
   $this->data = $this->Cost->giveMeProperData($this->params["named"];
}

A w widoku w zależności od tego w jaki sposób wygląda $this->data renderuj widok.
Jeśli w tablicy jest “entry_id” to wyświetl zawartość $this->data[“Entry”][“name”], a wartość $this->data[“Entry”][“id”] przypisz do jakiegoś ukrytego pola. Itd.

Co zyskujesz?

  1. Kontroler robi to co do niego należy. Przestaje go interesować przypadek właśnie rozpatrywany. Jego interesuje tylko czy został przesłany formularz. Jeśli tak – spróbuj zapisać i przekierować, jeśli nie – pobierz dla niego dane i wyświetl (przekaż do widoku)
  2. Testowanie modeli, choć nie tak proste, jest o niebo łatwiejsze od testowania kontrolerów i widoków – możesz pokryć ważną część aplikacji testami. Możesz testować, czy metoda giveMeProperData() zwraca to, czego się spodziewasz dla konkretnych argumentów.
  3. Jeśli pokryjesz tą metodę testami będziesz mógł w komfortowych warunkach refaktoryzować ten kod – jak się pojawią powtórzenia wyodrębnisz wspólne części itd. Gdyby to siedziała w kontrolerze – nikt nie miałby ochoty tam zajrzeć, a testowanie polegało by głównie na odświeżaniu widoków w przeglądarce dla każdego z przypadków.
  4. Teraz widok jest klasą, która “ma wiedzę” na temat tego jak się zachować w zależności od otrzymanych danych (w prostych przypadkach to już się dzieje – chociażby w akcjach “edit”). Wcześniej część tej wiedzy znajdowała się w kontrolerze.
  5. Przyjemny side-effect wynikający z używania parametrów “named”: Teraz komponując link w widoku zamiast zastanawiać się czy formularz dodawania kosztów dla danej firmy jest pod /costs/add//[id_firmy]/ czy pod /costs/add///[id_firmy] – wiem, że jest pod /costs/add/entry_id:[id_firmy]

Jest oczywiście brak w tym rozwiązaniu – dość sporo kodu wędruje do widoku (który, jak wspomniałem, trudno testować). Jednak pozostawienie architektury w pierwotnym stanie tej kwestii nie rozwiązuje. Na początku masz problem z

  • dużą ilością kodu w kontrolerze
  • kodem kontrolera, który nie jest pokryty testami więc nie będzie refaktoryzowany
  • kodem w widoku, który nie jest pokryty testami

Po zastosowanej zmianie zostanie Ci tylko ostatni punkt. Samodzielnie możesz zdecydować, czy to się opłaca.

Share Button