debuggable

 
Contact Us
 
15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23

CakePHP Argentina

Posted on 3/12/08 by Felix Geisendörfer

Sry folks, the tubes here in Argentina are pretty jammed sometimes. But Mark Story is live covering the whole event. Check it out and use it to convince your guiding powers for attending the next fest.

Will upload pictures and stuff here soon as well : ).

-- Felix Geisendörfer aka the_undefined

 

How to paginate a CakePHP search over a HABTM relation without hacking the core

Posted on 21/11/08 by Tim Koschützki

Hey folks,

this post is going to deal with the pretty common problem of paginating a search with the CakePHP framework.

The Problem

The problem is that a user inputs some search criteria into a form, the resultset exceeds 30 rows for example and the user must be able to browse through the resultset via different pages. The search criteria has to be the same everytime, the form prepopulated with the used search keywords/data and the resultset still has to match the input conditions everytime a new page is clicked.

This problem itself is in fact not much of a problem. We just need to store the form conditions somewhere and then hack it together. So what we are going to do is that we raise the difficulty bar a lot more by trying to get the pagination work over a HABTM relation.

Battlefield Briefing

We agreed we need to store the search criteria somewhere, so we can access it later. No we won't use the DB for that as it is overkill. We will also not use files, since many users may use the search at the same time screwing our hd. :P Yes, we will use the session for that as it is made exactly for these things.

For the example code we are going to use the "Advanced Search" of Flashfun247, a flashgame site which is one of my freetime projects. If you want to see some more of its code, feel free to ask.

We want to search for games based on some input conditions. The Game model is related to the GameCategory model over a HABTM relation, so the same game can be in many categories and a category contains many games. CakePHP's paginator cannot handle pagination over a HABTM so well in its current version. The incident here is that we want every game listed only once in the resultset - and not n times, where n is the number of categories it belongs to.

So we must at some point include a group by statement. However, the paginator will use that group by statement for its internal find('count') call as well, which it does to determine the size of the resultset. This will in fact corrupt the page count screwing us all over. We will see that we can trick the paginator, though. ; )

The View

To get us started, let's have a look at the view in /views/searches/advanced.ctp, which is very simple:

<h1><?php echo $this->pageTitle = 'Advanced Game Search'; ?></h1>
<?php
$html->addCrumb($this->pageTitle);
echo $form->create('Search', array('action' => 'advanced'));

if (isset($formData) && !empty($formData)) {
  $form->data = $formData;
}

echo $form->input('game_category_id', array('label' => 'Category:', 'options' => $searchCategories, 'empty' => 'All Categories'));
echo $form->input('keywords', array('label' => 'Text from game name, description or instructions:'));
echo $form->input('tags', array('label' => 'Is tagged with (separate tags by comma):'));
$orderOptions = array(
  'Game.name' => 'Name',
  'GameCategory.name' => 'Game Category',
  'Game.avg_rating' => 'Game Rating',
  'Game.clicks' => 'Number of Plays',
);
echo $form->input('order_by', array('label' => 'Order Results By:', 'options' => $orderOptions));
echo $form->input('order_dir', array('label' => 'Direction:', 'options' => array('asc' => 'Ascending', 'desc' => 'Descending')));
?>
<div class="clear"></div>
<?php echo $form->end('Search', array('action' => 'search'))?>

<?php if (isset($games)) : ?>
  <div class="clear"></div>

  <?php if (!empty($games)) : ?>
    <?php echo $this->element('../games/list', array('games' => $games, 'hilite' => $query))?>
    <div class="clear"></div>
    <?php echo $this->element('paging', array('model' => 'GameCategoriesGame'))?>
  <?php else : ?>
    <p class="error-message">Sorry, your search returned no results</p>
  <?php endif; ?>
<?php endif; ?>

It should be pretty straightforward. The only weird thing here is that $formData array. It is basically the placeholder for our search criteria that the user originally typed into the search form field. The view only needs to know where the form data is and not where it comes from. We simply assign the data to the form helper so it can prepopulate the fields for us (line 7).

The user can input here a substring of the name/description/instructions of a game and he can pick a category where the game must be in. Notice the different order options as well as the string "All Categories" for the empty option of the select tag. One other remarkable thing is that we have both isset($games) and !empty($games) calls there. This is to differentiate if the user has submitted the form already ( isset($games) ) and, if he did, the resultset is not empty which allows us to display that "Nothing found" message.

Here is the /views/games/list.ctp view just so you have the complete code:

<?php
$short = isset($short) ? $short : false;
$class = $short ? ' short' : '';
?>
<div class="games-list">
  <?php foreach ($games as $game) : ?>
    <div class="game<?php echo $class ?>">
      <div class="game-image">
        <?php echo $this->element('game_image', array('game' => $game, 'thumb' => true))?>
      </div>
      <div class="game-descr">
        <?php
        $name = $game['Game']['name'];
        if (isset($hilite)) {
          $name = $text->highlight($game['Game']['name'], $hilite);
        }
        echo $html->link($name, Game::url($game), null, false, false);
        ?>
        <?php if (!$short) : ?>
         
<?php echo $game['Game']['short_desc'] ?>

          <div class="plays"><span>Plays:</span> <?php echo $game['Game']['game_playing_count']?></div>
        <?php endif; ?>
      </div>
    </div>
  <?php endforeach; ?>
</div>
?>

Straightforward... Let's move on to the paging element:

<?php
if (!isset($model) || $paginator->params['paging'][$model]['pageCount'] > 1) : ?>
<div class="paging">
  <?php echo $paginator->prev('&laquo; Previous', array('escape' => false, 'class' => 'prev'), null, array('class'=>'disabled'));?>
  <?php echo $paginator->numbers();?>
  <?php echo $paginator->next('Next &raquo;', array('escape' => false, 'class' => 'next'), null, array('class'=>'disabled'));?>
</div>
<?php endif; ?>

Notice the different checks at the start in order to figure out if we need to display a div at all.. This is called in the advanced.ctp view and the model GameCategoriesGame is supplied, which is a convenience HABTM model which belongsTo both Game and GameCategory.

The controller action

The controller action might appear a little big at first glance. However, every line has its purpose. This is in a SearchesController. You could have your own search() method though in about any controller.

function advanced() {
    $searchCategories = $this->Game->GameCategory->find('list', compact('conditions'));
    $this->set(compact('searchCategories'));

    $page = 1;
    if (isset($this->params['named']['page'])) {
      $page = $this->params['named']['page'];
    }

    $formData = array();
    $sessionKey = 'advanced_search_query';
    if (isset($this->data['Search']['keywords'])) {
      $formData = $this->data;
      $this->Session->write($sessionKey, $formData);
    } elseif ($this->Session->check($sessionKey)) {
      $formData = $this->Session->read($sessionKey);
    } else {
      Assert::true(false, '404');
    }
    $this->set(compact('formData'));


    if (!empty($formData)) {
      $query = $formData['Search']['keywords'];
      $useQuery = trim(low($query));

      $conditions = array();
      if (!empty($formData['Search']['game_category_id'])) {
        $conditions['GameCategoriesGame.game_category_id'] = $formData['Search']['game_category_id'];
      }
      $conditions = am($conditions, array(
        'Game.published' => '1',
        'or' => array(
          'LOWER(Game.name) LIKE' => "%{$useQuery}%",
          'LOWER(Game.short_desc) LIKE' => "%{$useQuery}%",
          'LOWER(Game.long_desc) LIKE' => "%{$useQuery}%",
          'LOWER(Game.instructions) LIKE' => "%{$useQuery}%"
        )
      ));

      $this->GameCategoriesGame->forcePaginateCount = $this->GameCategoriesGame->paginatorCount(
        'game_categories_games', $conditions, array('Game')
      );
      $contain = array('GameCategory', 'Game.Tag');
      $order = array('Game.name' => 'asc');

      if (!empty($formData['Search']['order_by'])) {
        $order = array($formData['Search']['order_by'] => $formData['Search']['order_dir']);
      }

      $this->paginate['GameCategoriesGame'] = array(
        'conditions' => $this->GameCategoriesGame->paginatorConditions('game_categories_games', $conditions),
        'contain' => $contain,
        'order' => $order,
        'limit' => 12
      );
      $games = $this->paginate('GameCategoriesGame');
      $this->set(compact('games', 'query'));
    }
  }

So we are first loading all our game categories to populate the select tag. Then we check if there is a named parameter "page" given. If so, the user clicked on the Previous/Next/Numbered links. If it is not present, we might as well start at page 1. ; ]

Now comes the tricky part. We check if the form was submitted via empty($this->data). If it is submitted, we store all the form data in the session. If the form is not submitted we try to recover the form data from the session. If both the form is not submitted and there is no data in the session, but it still a Get request, something bad happened and we fire the user by asserting the yummyness of his cake.

The rest should be familiar - some processing of the $formData array to extract the proper conditions and order stuff. The most interesting stuff now is that call to $this->GameCategoriesGame->paginatorCount('game_categories_games', $conditions, array('Game'));. This enables us to paginate over the HABTM relation (Game HABTM GameCategory). Here is the code from the GameCategoriesGame model:

<?php
class GameCategoriesGame extends AppModel {
  var $name = 'GameCategoriesGame';
  var $belongsTo = array('GameCategory', 'Game');

/**
 * Return count for given pagination
 *
 * @param string $paginator Pagination name
 * @param array $conditions Conditions to use
 * @return mixed Count, or false
 * @access public
 */

  function paginatorCount($paginator, $conditions = array(), $contain = array()) {
    $Db = ConnectionManager::getDataSource($this->useDbConfig);
    if (!empty($contain)) {
      $related = ClassRegistry::init($contain[0]);
    }

    $sql = 'SELECT
      COUNT(DISTINCT '
. $this->alias . '.' . $this->belongsTo['Game']['foreignKey'] . ') count
    FROM '
. $Db->fullTableName($this->table) . ' ' . $Db->name($this->alias) . ' ';
    if (!empty($contain)) {
      $sql .= ' INNER JOIN ' . $Db->fullTableName($related->table) . ' ' . $Db->name($related->alias) . ' ';
    }
    $sql .= $Db->conditions($this->paginatorConditions($paginator, $conditions, 'count'));

    $count = $this->query($sql);

    if (!empty($count)) {
      $count = $count[0][0]['count'];
    }
    return $count;
  }
/**
 * Build conditions for given pagination
 *
 * @param string $paginator Pagination name
 * @param array $extraConditions Extra conditions to use
 * @param string $method 'count', or 'find'
 * @return array Conditions
 * @access public
 */

  function paginatorConditions($paginator, $extraConditions = array(), $method = null) {
    $Db = ConnectionManager::getDataSource($this->useDbConfig);
    $conditions = null;
    if (empty($extraConditions)) {
      $extraConditions = array('1=1');
    }
    switch (strtolower($paginator)) {
      case 'game_categories_games':
        if ($method != 'count') {
          $conditions = array_merge($extraConditions, array('1=1 GROUP BY ' . $this->alias . '.' . $this->belongsTo['Game']['foreignKey']));
        } else {
          $conditions = $extraConditions;
        }
        break;
    }
    return $conditions;
  }
/**
 * Executed by the paginator to get the count. Overriden to allow
 * forcing a count (through var $forcePaginateCount)
 *
 * @param array $conditions Conditions to use
 * @param int $recursive Recursivity level
 * @return int Count
 * @access public
 */

  function paginateCount($conditions, $recursive) {
    if (isset($this->forcePaginateCount)) {
      $count = $this->forcePaginateCount;
      unset($this->forcePaginateCount);
    } else {
      $count = $this->find('count', compact('conditions', 'recursive'));
    }
    return $count;
  }
}
?>

To make a long story short: You see we build up the count query on our on and then force Cake to use our calculated count via our own forcePaginateCount property of the model. The Group BY is already in there, we can supply extra conditions and have different queries for different types (see the switch statement in paginatorConditions).
Alas, we have to build the sql on our own for the JOINs, which can become a headache for more complex problems. Anyway, this code gives us enough flexibility to build the right pagination for every problem. :) If you can think of a problem this code cannot be used for, please let me know and we discuss.

The paginateCount() method could go into your AppModel, I just put it here to have the code in one place to keep it simpler.

Conclusion

The method presented has some advantages and disadvantages, as always. The advantages would definitely include that we don't have to extend the controller's paginate() method in our app controller. This is what many people do and what I did in the past as well. However, as always, it's not good manners to hack the core.
Another advantage is the flexibility of the code - with just one line, we can calculate pagination counts for almost every occasion, and even if we paginate over two or three HABTM relations (I can show you later).

Disadvantages include some bloat in your models and the need to write sql again (*sigh*), which can become very complex if you have to supply all the JOINS yourself for more complex problems. Apart from that the code does not yet have full integration of the containable behavior. However, that I can add later.

I hope you liked the article and can put it to some use. Credits go to mariano for the original idea for this. If you guys are interested in seeing how I coupled the "Save search" feature from here with all of this, feel free to ask and we can have some nice discussion.

 

First CakeBar meetup in Munich

Posted on 18/11/08 by Felix Geisendörfer

Hey folks,

a bunch of bakers are starting to organize a regular CakePHP meetup in Munich. If you are interested check out their website. The first meeting is on Thursday 7:30pm!

Out of curiosity, anybody interested in having a meetup here in Berlin? Let me know and Tim and I will organize something after CakeFest.

-- Felix Geisendörfer aka the_undefined

 

Buchtipp: Webentwicklung mit CakePHP

Posted on 6/11/08 by Felix Geisendörfer

Hey folks,

this is going to be my first blog post in German ever, which is borderline madness considering it is my mother language. Anyway, the book I'm talking about is written in German so it makes little to no sense to review it in English.

Das Buch Webentwicklung mit CakePHP ist ohne Übertreibung *die beste* Veröffentlichung, die es zur Zeit zum Thema CakePHP zu erwerben gibt. Ich hatte die Gelegenheit, in einige CakePHP Bücher (größtenteils Englisch) hineinzuschnuppern und es gibt derzeit wirklich nichts Vergleichbares auf dem Markt.

Doch zunächst einmal, an wen richtet sich das Buch? In Kapitel 2 gibt es eine Hilfestellung zur Installation eines lokalen Webservers. Kompletten PHP / LAMP Neulingen würde ich dennoch vom Kauf abraten. Ohne solide Grundkenntnisse in PHP und prinzipiellem OOP Verständnis wird man schnell untergehen. Wenn man diese Hürde allerdings genommen hat ist das Buch eine wahre Goldgrube. Eigentlich traurig, aber wahr: Auch Tim und ich als alte Cakehasen haben eine Menge Dinge über CakePHP gelernt die uns bis dato unbekannt waren.

Der Hauptunterschied des Buches gegenüber ähnlichen Büchern am Markt ist jedoch ein ganz anderer. Die Kommunikation der Philosophie. Kapitel 1 beschäftigt sich ausschließlich damit, zu erklären, was ein Framework überhaupt ist, warum man es verwenden sollte, was MVC ist etc. Klar, jedes Buch macht hier einen gewissen Versuch, die Grundidee zu vermitteln, aber Dirk & Robert gelingt es perfekt, die Motivation der Core-Entwickler zu verstehen und zu vermitteln. Dies ist unglaublich nützlich wenn es später darum geht, bestimmte Entscheidungen des Frameworks im Bezug auf Conventions, DRY (Don't repeat yourself), MVC, etc. zu verstehen.

Auch der restliche Aufbau des Buches ist gut durchdacht. Ein Blick in das Inhaltsverzeichnis verrät: Schon in Kapitel 3 wird mit der ersten Datenbankanbindung und der Scaffolding Funktion zur Sache gegangen. Anschließend folgen ganz klassisch Erklärungen zum Controller, Model, View, Helpers, Session handling, Components, etc.. Soweit so gut, das findet man auch noch anderswo erklärt.

Wo sich das Buch wirklich lohnt sind die schwierigeren Themen. Die Nummer 1 ist ohne Frage ACL. Es geht ja das böse Gerücht um, das lediglich Nate Abele (Entwickler der ACL Funktionalität in CakePHP) in der Lage ist, mit cake's ACL System zu arbeiten. Pustekuchen. Oder in den Worten der Autoren: "Wenn der Kuchen spricht, haben die Krümel zu schweigen". Das ACL Kapitel des Buches ist aus meiner Sicht für sich allein genommen den Kauf Wert. In ~20 Seiten wird einem ein exzellentes Verständnis der Architektur inkl. MPTT gegeben und an praktischen Beispielen erklärt, wie der Hase - pardon - Kuchen läuft.

Aber auch die anderen Kapitel müssen sich nicht verstecken. Das Buch erklärt auch i18n, AJAX, Unit testing, Plugins und Fehler Behandlung. Immer perfekt gelungen ist der Kompromiss der Informationsselektion. Klar, an vielen Stellen könnte man noch mehr Schreiben, aber die Autoren setzen sehr gute Prioritäten zwischen wichtigen und unwichtigen Informationen. Somit wird das Lesen an keiner Stelle langweilig und man kann rasche Fortschritte erzielen.

Um jetzt nicht des Lobbyismus für O'Reilly beschuldigt zu werden: Ja es gibt auch ein paar kleine Fehler die sich im Buch eingeschlichen haben. Davon ist allerdings lediglich einer frustrierend wenn es um die Sortierung von Model results geht, da wird an einer Stelle ein falscher parameter verwendet. Die restlichen Fehler haben eher kosmetische Dimensionen und tun dem Verständnis keinen Abbruch (zumal es sich wirklich um lediglich 3-4 handelt).

Nichts desto trotz: Wer des Deutschen mächtig ist und sich selbst oder einem Kollegen CakePHP näher bringen möchte sollte unbedingt zugreifen. Bei "Webentwicklung mit CakePHP" handelt es sich wirklich um ein außergewöhnliches Programmierbuch wo fast jedes Kapitel für sich den Kauf rechtfertigen kann.

-- Felix Geisendörfer aka the_undefined

PS: I have met Robert, one of the authors twice. He did however not even bother to bribe me with a beer so far (even when Tim and I visited him for Oktoberfest in Munich) so trust me if I say that I truly enjoyed reading this book and my endorsement for it comes from heart.

 

Unix is love

Posted on 4/11/08 by Felix Geisendörfer

git ci -m "Updated sidebar" && git push && deploy blog && say "Done"

No, this is not pseudo code. I just ran this : ).

Also read the previous "use the terminal" post.

-- Felix Geisendörfer aka the_undefined

 
15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23