多くのウェブアプリケーションにおいて、アイテムを並び替えるためのインターフェースを提供する必要があります。たとえば、ブログにおけるカテゴリーや CMS における記事や、eコマースサイトにおける購入予定の一覧などを想像してみてください。これまでの流行は1つのアイテムをリストで上下移動させるための矢印であったりします。AJAXでは、直接ドラッグアンドドロップで並び順を変えることができます。この章では、両方について、あなたのオブジェクトモデルを改善し、Creole を用いて複雑なクエリを処理する方法についていくつかのトピックスと共に説明しましょう。

この記事のために、利用される例は未定義のItemテーブルです。これは必要にあわせて変更してください。並び替えできるようにするために、レコードは少なくとも rank フィールドが必要で、ここではユーザーによって並び替えが行われるのでヒープ構造の必要はありません。そのため、(schema.ymlに記述される) データ構造はシンプルなものです。
propel:
test_item:
_attributes: { phpName: Item }
id:
name: varchar(255)
rank: { type: integer, required: true }
コマンドラインで入力することにより、データ構造が定義され、モデルが構築されていることを確認してください。
$ symfony propel-build-model
同じ構造のデータベースも必要です。もっとも手っ取り早い方法は次のように呼び出しを行うことです。
$ symfony propel-build-sql
$ symfony propel-insert-sql
ユーザーインターフェースを考える前に、ランクで順序付けされたアイテムのリストを取得するために、ランクでアイテムを取得する方法についてと、 lib/model/ItemPeer.php に次のような関数を追加することにより、現在の最大ランクを取得する方法についてを確認します。
static function retrieveByRank($rank = 1) { $c = new Criteria; $c->add(self::RANK, $rank); return self::doSelectOne($c); } static function getAllByRank() { $c = new Criteria; $c->addAscendingOrderByColumn(self::RANK); return self::doSelect($c); } static function getMaxRank() { $con = Propel::getConnection(self::DATABASE_NAME); $sql = 'SELECT MAX('.self::RANK.') AS max FROM '.self::TABLE_NAME; $stmt = $con->prepareStatement($sql); $rs = $stmt->executeQuery(); $rs->next(); return $rs->getInt('max'); }
これらの関数は両方の並び替えのインターフェースにおいてかなり利用されるでしょう。もし、symfony におけるオブジェクトモデルがデータベースへの問い合わせをどのように処理しているかについて知りたいのであれば、Propelユーザーガイドの基本的 CRUD についてを確認してください。
lib/model/Item.php クラスに追加される必要がある2つの関数があります。ここでは必要がありませんが、恐らく実際のアプリケーションで、テーブルにアイテムの追加や削除する場所で、必要となるでしょう。
public function save($con = null) { // New records need to be initialized with rank = maxRank +1 if(!$this->getId()) { $con = Propel::getConnection(ItemPeer::DATABASE_NAME); try { $con->begin(); $this->setRank(ItemPeer::getMaxRank()+1); parent::save(); $con->commit(); } catch (Exception $e) { $con->rollback(); throw $e; } } else { parent::save(); } } public function delete($con = null) { $con = Propel::getConnection(PagePeer::DATABASE_NAME); try { $con->begin(); // decrease all the ranks of the page records of the same category with higher rank $sql = 'UPDATE '.ItemPeer::TABLE_NAME.' SET '.ItemPeer::RANK.' = '.ItemPeer::RANK.' - 1 WHERE '.ItemPeer::RANK.' > '.$this->getRank(); $con->executeQuery($sql); // delete the item parent::delete(); $con->commit(); } catch (Exception $e) { $con->rollback(); throw $e; } }
レコードの追加と削除は rank フィールドの整合性のために、注意深く管理しなければなりません。そういうわけで、save() と delete() 関数は特別な関数になっています。これらの関数は複雑な 読み書き操作を行い、同時進行でのリスクを発生させてしまうので、これらの操作は transaction に閉じ込められます(symfonyにおけるtransctionについての詳細についてはPropel documentation を参照)。
このチュートリアル内で説明された相互作用は item モジュールで行われます。(frontend アプリケーションを想定して)次のように呼び出し初期化します。
$ symfony init-module frontend item
ウェブサーバー設定はあなたのお気に入りのブラウザを通して新しいモジュールへアクセスするテストでOKかどうか確認してください。もしsandboxでチュートリアルを行っているなら次のURLをチェックすべきです。
http://localhost/sf_sandbox/web/frontend_dev.php/item
最後に、アイテムの並び替えをテストしたいなら、アイテムが必要でしょう。テストアイテムのデータを CRUD interfaceか population file で生成してください。
これで、全てがそろいました。でははじめてみましょう。
古典的な並び替えのリストは各アイテムがその順番を変更するコントロールを用意していました。最初に、このリストを表示するためのアクションとテンプレートを作成します。
// add to modules/item/actions/actions.class.php public function executeList() { $this->items = ItemPeer::getAllByRank(); $this->max_rank = ItemPeer::getMaxRank(); } // create a template listSuccess.php in modules/item/templates/ <h1>Ordered list of items</h1> <ul> <?php foreach($items as $item): ?> <li> <?php echo $item->getName().' '; if($item->getRank() > 0): echo link_to('Move up ', 'item/up?id='.$item->getId()); endif; if($item->getRank() != $max_rank): echo link_to('Move down', 'item/down?id='.$item->getId()); endif; ?> </li> <?php endforeach ?> </ul>
アイテムを上下させるリンクは操作が可能なときだけ表示されます。最初のアイテムはそれ以上上に移動できませんし、最後のアイテムもそれ以上下に移動できないからです。正しく表示されるかどうか確認します。
http://localhost/sf_sandbox/web/frontend_dev.php/item/list
さて、item/up と item/down を見てみましょう。 up アクションはパラメータにとして与えられたページのランクを減少し、前のページのランクを増加することができます。down パラメータとして与えられたページのランクを増加し、次のページのランクを減少させます。これら両方は2つの書き込み操作をデータベースに対して行っています。これらのアクションはtransactionを利用すべきです。
2つのアクションはとても似たロジックで、もしD.R.Y.のままにしておきたいなら、それらをコードを繰り返さないよりスマートな方法にするとが分かるでしょう。swapWith() を Item.php モデルクラスに追加することによって可能です。
public function swapWith($item) { $con = Propel::getConnection(ItemPeer::DATABASE_NAME); try { $con->begin(); $rank = $this->getRank(); $this->setRank($item->getRank()); $this->save(); $item->setRank($rank); $item->save(); $con->commit(); } catch (Exception $e) { $con->rollback(); throw $e; } }
そして、up と down のアクションはかなりシンプルなものになります。
public function executeUp() { $item = ItemPeer::retrieveByPk($this->getRequestParameter('id')); $this->forward404Unless($item); $previous_item = ItemPeer::retrieveByRank($item->getRank() - 1); $this->forward404Unless($previous_item); $item->swapWith($previous_item); $this->redirect('item/list'); } public function executeDown() { $item = ItemPeer::retrieveByPk($this->getRequestParameter('id')); $this->forward404Unless($item); $next_item = ItemPeer::retrieveByRank($item->getRank() + 1); $this->forward404Unless($next_item); $item->swapWith($next_item); $this->redirect('item/list'); }
forward404Unless() を呼び出すことによるセキュリティチェックが必要ないのであれば、これらのアクションはよしシンプルなままです。しかし、不正なリクエスト-たとえば直接URLを入力することによるアクセスなど-に対してアプリケーションを保護する必要があるなら、
このリストは完全に機能します。リストを上げ下げしてみてください。
基本的な AJAX で並び替えが可能がリストを開発することは一昔前にくらべると難しいものではありません。作業のほとんどは sortable_element() という特別な JavaScript ヘルパーを呼び出すことです。
// add to modules/item/actions/actions.class.php public function executeAjaxList() { $this->items = ItemPeer::getAllByRank(); } // ajaxListSuccess.phpテンプレートをmodules/item/templates/に作成します。 <?php use_helper('Javascript') ?> <style> .sortable { cursor: move; } </style> <h1>Ordered list of items - AJAX enabled</h1> <ul id="order"> <?php foreach($items as $item): ?> <li id="item_<?php echo $item->getId() ?>" class="sortable"> <?php echo $item->getName() ?> </li> <?php endforeach ?> </ul> <div id="feedback"></div> <?php echo sortable_element('order', array( 'url' => 'item/sort', 'update' => 'feedback', )) ?>
次のように入力することでチェックアウトします。
http://localhost/sf_sandbox/web/frontend_dev.php/item/ajaxlist
sortable_element() という JavaScript ヘルパーのマジックによって、<ul> 要素は並び替えができるようになります。そしてこれはそれらの子要素はドラッグ&ドロップによって再び並び替えられることを意味します。ドラッグ、リリースするたびにリストは並び代わり、AJAX リクエストが次のようなパラメータで行われます。
POST /sf_sandbox/web/frontend_dev.php/item/sort HTTP/1.1
order[]=1&order[]=3&order[]=2&order[]=4&order[]=5&order[]=6&_=
完全に並び替えられたリストは(order[$rank]=$id に整形され、$order 文字列は 0で始まり、リストの要素idプロパティーのアンダースコア(_)の後にくるものに基づいている$idの)配列として渡されます。並び替え可能な要素のidプロパティー(例の中ではorder)はパラメータを名付けるのに利用されます。JavaScript ヘルパーはurlアクション(例におけるitem/sort)へのために XMLHttpRequest を作ります。POSTモードで並び替えられたリストは渡され、アクションの結果をID要素を更新するために利用します(例におけるfeedback dev` がそうです)。
ここで、item/sort アクションを書いて、アイテムのリストがどのように並びかえられるかを見てみましょう。
[php]
// add to modules/item/actions/actions.class.php
public function executeSort()
{
$order = $this->getRequestParameter('order');
$flag = ItemPeer::doSort($order);
return $flag ? sfView::SUCCESS : sfView::ERROR;
}
全体のリストを並び替えるための機能はモデルの機能の一部です。そういうわけで ItemPeer クラスの静的関数を実装しています。もう一度いいますが、この関数が多くの item テーブルのレコードを更新するということは、データベースに密接にかかわりがある必要があります。
static function doSort($order) { $con = Propel::getConnection(self::DATABASE_NAME); try { $con->begin(); foreach ($order as $rank => $id) { $item = ItemPeer::retrieveByPk($id); if($item->getRank() != $rank) { $item->setRank($rank); $item->save(); } } $con->commit(); return true; } catch (Exception $e) { $con->rollback(); return false; } }
関数からの戻り値はどのテンプレートをアクションが表示すべきかを決定します。次のようなテンプレートをあなたの modules/item/templates/ ディレクトリに追加します。
// sortSuccess.php Ok // sortError.php <strong>A problem occurred. Please refresh and try again.</strong>
リストを変更したあとにF5(更新ボタン) を押してサーバーとのやりとりをテストしてください。並び順は変化すべきではなく、サーバーが正しく送信された AJAX リクエストが何かを理解し保存することがわかるでしょう。
sortable_element() オプションにフォーカスするJavascriptヘルパーの章 でリモート関数呼び出しの一般的なオプションについて説明しましたが、この例では詳細に sortable_element() のオプションについて実際にみる良い機会です。
hoverclass パラメータで他の要素にドラッグされたとき、あなたは異なるマウスで捕らえられたリストのエレメントの出現を定義することができます。
<?php use_helper('Javascript') ?> <style> .sortable { cursor: move; } .hovered { font-weight: bold; } </style> ... <?php echo sortable_element('order', array( 'url' => 'item/sort', 'hoverclass' => 'hovered', )) ?>
並び替えができない要素をリストに追加し、only パラメータで1つだけのクラスにドラッグ&ドロップを制限することができます。
... <ul id="order"> <?php foreach($items as $item): ?> <li id="item_<?php echo $item->getId() ?>" class="sortable"> <?php echo $item->getName() ?> </li> <?php endforeach ?> <li>This element is not part of the ordered list</li> </ul> <?php echo sortable_element('order', array( 'url' => 'item/sort', 'only' => 'sortable', )) ?>
もし、前例のように縦方向で表示させたくないのであれば、horizontal に overlap パラメータをセットしなければなりません。
<?php use_helper('Javascript') ?> <style> .sortable { cursor: move; float: left; } </style> ... <?php echo sortable_element('order', array( 'url' => 'item/sort', 'overlap' => 'horizontal', )) ?>
並び替えのためのリストが1組の <li> 要素でなければ、どの並び替え可能な子要素がドラッグ可能かを定義しなければなりません。
... <div id="order"> <?php foreach($items as $item): ?> <div id="item_<?php echo $item->getId() ?>" class="sortable"> <?php echo $item->getName() ?> </div> <?php endforeach ?> <p>This cannot be dragged</p> </div> <?php echo sortable_element('order', array( 'url' => 'item/sort', 'tag' => 'div', )) ?>
全てのAJAX アクションのために、バックグラウンドでの動きとリクエスト成功の眼に見えるフィードバックを持っておくことは良いことです。
<div id="feedback"></div> <div id="indicator" style="display:none;"><img src="/images/activity_indicator.gif" style="display:none;"/></div> <?php echo sortable_element('order', array( 'url' => 'item/sort', 'update' => 'feedback', 'loading' => "Element.show('indicator')", 'complete' => "Element.hide('indicator')", 'success' => visual_effect('highlight', 'feedback'), )) ?>
これらのパラメータについての詳細やここで述べられていないことについては、script.aclos.usにある並び替えマニュアルを参照してください。
2つの関数はそれぞれリストの並び替えを効果的に行えますが、制限や欠点があります。
巨大な項目の配列のために、ページ化されたリストが必要になるでしょう。いままではページ毎にリストを取得し動作する関数でしたが、AJAX の関数においてはアダプテーションが必要であり、それにより自身のページの外側で要素を再構成させないようにできます。こういうわけでAJAX 並び替えインターフェイスに'アイテムの位置を移動させる'機能を作ってみましょう。
AJAX アクションは今までのもののような不正なリクエストに対して十分に保護されていません。データベースに矛盾が生じないようにするために、validateSort() 関数を itemActions クラスに追加しなければなりません。この関数は全てのアイテムidが正しいかどうか、それらが以前受け取ったものだけかどうかを確認します。
AJAXによる並び替えで利用される ItemPeer::doSort() 関数の1つの欠点は並び替えられるたびに必要なクエリの数です。リストでアイテムを移動させるたびに少なくとも n+2 回のクエリがデータベースに投げられます。AJAXリストは巨大なリストには向いていません。というのはあまり大きな問題ではありません。しかしパフォーマンスが関係してくるのであれば、UPDATE table SET CASE/WHEN SQL 構文を利用してたった1回のクエリで順位を更新できる関数になるようにリファクタリングしなければなりません。
AJAX インターフェイスは間違いなくユーザーにとって使いやすいものです。とくに、大幅に順番を並び替える作業においてがそうです。なぜならば、2回の作業においてサーバーへアクセスしブラウザを更新する義務がないからです。しかし、ブラウザのインターフェイスで要素をドラッグできることは新しいことであり、ユーザーは慣れておらず、驚くかもしれません。さらに、AJAX インターフェイスを採用するなら、ドラッグできる要素のサイズ (それらが掴むのに十分な大きさかどうか)、それらの外観、動かすときの自由さ...など、従来の関数で解決する必要がなかった、多くの人間とコンピューターとの対話における問題について考えなければなりません。
利用者がブラウザの JavaScript をオフにしているかもしれないという問題を、AJAX インターフェイスはいつも問題を抱えています。JavaScript インターフェイスのデザインにおいても言えることであり、機能性が大幅に落とした代わりとなる従来のインターフェイスを用意すべきです。
全ての人のために、AJAX バージョンは本当に良いものに思えますが、開発には少なくとも2倍かかります。