Displaying posts filed under

CakePHP

find(‘list’) で集約関数を使用する方法

単に field パラメータに指定するだけではうまく動作しないので、一旦 find(‘all’) で結果を取得してから Set::combine を使って同等の構造を得る。

// user_id => 件数
$tmp = $Post->find('all',
                   array('fields' => array('user_id', 'COUNT(*) AS posts_count'),
                         'group'  => 'user_id'));
return Set::combine($tmp, '{n}.Post.user_id', '{n}.0.posts_count');

Set::combine は恐ろしく多機能なので、find(‘list’) をそのまま使うよりずっと多くの事ができる。例えば次のように、複数カラムで GROUP BY した結果をフラットな配列にまとめることができる。

// "user_id:category_id" => 件数
$tmp = $Post->find('all',
                   array('fields' => array('user_id', 'category_id', 'COUNT(*) AS posts_count'),
                         'group'  => array('user_id', 'category_id')));
return Set::combine($tmp,
                    array('{0}:{1}', '{n}.Post.user_id', '{n}.Post.category_id'),
                    '{n}.0.posts_count');

値を連想配列にする事もできる。

// user_id => array("created_max" => 最大値, "created_min" => 最小値)
$tmp = $Post->find('all',
                   array('fields' => array('user_id',
                                           'MAX(created) AS created_max',
                                           'MIN(created) AS created_min'),
                         'group'  => 'user_id'));
return Set::combine($tmp, '{n}.Post.user_id', '{n}.0');

メールの送信をテストする

環境: CakePHP1.3

ComponentTestCaseを使ってメールの送信をテストする方法をまとめておきます。Qdmailerを使うことを前提としていますが、標準のEmailComponentなど他のコンポーネントでも同じようにテストできると思います。

単純なケース

メール送信をテストする上で最も重要なことは、メールの種類ごとにコンポーネントを作るということです。

まずメール送信コンポーネントに共通の振る舞いを定義するため、以下のような Mailer クラスを app/libs に作ります。 実際のインターフェイスや初期化処理などは自分のアプリケーションに合わせて調整してください。

abstract class Mailer extends Object
{   
  var $components = array('Qdmailer.Qdmailer');
  var $_controller;
  var $_settings;
 
  function initialize($controller, $settings=array()) {
    $this->_controller = $controller;
    $this->_settings = $settings;
  }
 
  function create() {
  }
 
  protected function _rawSend() {
    $this->Qdmailer->send();
  }
 
  function send() {
    $this->Qdmailer->resetHeaderBody();
    $args = func_get_args();
    $this->dispatchMethod('create', $args);
    $this->_rawSend();
  }
}

次にメールの種類に合わせたメール送信用コンポーネントを作ります (他のコンポーネントとの混在を防ぐために components/mailers などサブディレクトリに作成することをおすすめします)。

先ほど作った Mailer クラスを継承して create メソッドを実装します。 メールの送信に使用する値は、create の引数で受け渡すよりは、 コントローラの viewVars 経由で受け取る方が良いと思います(メールテンプレートのレンダリングでも必要になるので)。

App::import('Lib', 'Mailer');
 
class RegisterRequestMailerComponent extends Mailer
{
  function create() {
    $email = $this->_controller->viewVars['User']['email'];
 
    $q = $this->Qdmailer;
    $q->resetHeaderBody();
    $q->to($email);
    $q->subject('ユーザー登録が完了しました');
    $q->cakeText('', 'register');
  }
}

単体テストではこの create メソッドをテストします(Mailerクラスに対するテストも書く必要がありますが省略)。 特定の入力(引数、viewVars)に対して宛先、送信元、そして本文などが期待通りに作成できるかどうかをテストします。

class RegisterMailerComponentTest extends ComponentTestCase
{
  var $components = array('RegisterMailer');
 
  function testCreate() {
    //viewVarsの登録
    $data = array('User' =>
                  array('id' => 123,
                        'username' => 'xxxxxx',
                        'email' => 'abc@example.com'),
                  'login_url' => 'http://example.com/register');
    $this->Controller->set($data);
 
    $this->RegisterMailer->create();
    $q = $this->RegisterMailer->Qdmailer;
 
    //本文
    $body = mb_convert_encoding($q->content['TEXT']['CONTENT'],
                                'utf-8', $q->charset_content);
    $this->assertTrue(strpos($body, $data['login_url']) !== false);
 
    //宛先など
    $this->assertEqual(1, count($q->to));
    $this->assertEqual($data['User']['email'], $q->to[0]['mail']);
  }
}

このコンポーネントを使用するコントローラからは、send メソッドを呼ぶだけです。

class UsersController extends AppController
{
  var $components = array('RegisterMailer');
 
  function finish() {
    //...
    if($this->User->save()) {
      $this->RegisterMailer->send();
    }
  }
}

より複雑なケース

次に一度に複数のメールを送る場合について考えてみます。 例えば登録完了メールをユーザと管理者に送るとしましょう。 この場合は「ユーザ宛メール(UserRegisterMailer)」と「管理者宛メール(AdminRegisterMailer)」の他に、 それらをまとめる「RegisterMailer」という3つの Mailer クラスを作成します。

UserRegisterMailer と AdminRegisterMailer は単純な例と同じように作ってテストしてください。 RegisterMailer では $components 配列を使ってそれら2つを読み込み、送信処理を委譲します。

App::import('Lib', 'Mailer');
 
class RegisterMailerComponent extends Mailer
{
  var $components = array('UserRegisterMailer',
                          'AdminRegisterMailer');
 
  function sendUserMail() {
    //viewVarsの加工処理など...
    $this->UserRegisterMailer->send();
  }
 
  function sendUserMail() {
    //viewVarsの加工処理など...
    $this->AdminRegisterMailer->send();
  }
 
  function send() {
    $this->sendUserMail();
    $this->sendAdminMail();
  }
}

このクラスに対するテストはモックを使って行うことができます。 本文や宛先の生成処理は移譲先でテストされているので、 移譲先に対する入力の形式と、適切なメソッドの呼び出しが行われていることをテストすれば十分です。

App::import('Component', 'UserRegisterMailer');
App::import('Component', 'AdminRegisterMailer');
Mock::generate('UserRegisterMailerComponent');
Mock::generate('AdminRegisterMailerComponent');
 
class RegisterMailerComponentTest extends ComponentTestCase
{   
    var $components = array('RegisterMailer');
 
  function startTest($m) {
    parent::startTest($m);
 
    $this->RegisterMailer->UserRegisterMailer
	  = new MockUserRegisterMailerComponent();
 
    $this->RegisterMailer->AdminRegisterMailer
	  = new MockAdminRegisterMailerComponent();
  }
 
  function testSendUserMail() {
    $this->Controller->set(array(/* ... */));
 
    $this->RegisterMailer->UserRegisterMailer->expectOnce('send');
    $this->RegisterMailer->sendUserMail();
    //...$this->Controller->viewVars が適切に加工されていることなどを確認...
  }
 
  function testSend() {
    $this->RegisterMailer->UserRegisterMailer->expectOnce('send');
    $this->RegisterMailer->AdminRegisterMailer->expectOnce('send');
    $this->RegisterMailer->send();
  }
}

このコンポーネントの使い方は「単純な場合」と全く同じで、コントローラは一切変更する必要がありません。

複雑なコンポーネントの単体テスト

環境: CakePHP1.3

単純な場合のテスト方法はクックブックに載っていますが、 より複雑な場合(コントローラや他のコンポーネントと連携する場合)は多くの準備作業が必要です。

その作業を省略するための ComponentTestCase クラスを作りました。ソースコードはとりあえずgistに置いておきます。

component_test_case.php

適当な場所 app/tests/libs などに置いてテストファイルの中で読み込んでください。

Fooコンポーネントをテストする場合の例は次のようになります。

require_once (TESTS .'libs/component_test_case.php');
 
class FooTestCase extends ComponentTestCase
{
  //コントローラの $components と全く同じ書式
  var $components = array('Foo' => array('a' => 'b'));
 
  function testAdd() {
    //$this->Controller でコントローラにアクセスできる
    $this->Controller->set(array('var1' => 1, 'var2' => 2));
 
    //$this->{コンポーネント名} でコンポーネントのインスタンスにアクセスできる
    $this->assertEqual(3, $this->Foo->add());
 
    //Fooの中で別のコンポーネントBarを読み込むことも可能
    //$components = array('Bar');
    $this->Foo->Bar->barmethod();
  }
}

モデルを使用することもできます。

class BarTestCase extends ComponentTestCase
{
  var $components = array('Bar');
  var $uses = array('User');
  var $fixtures = array('app.user');
 
  function testSomething() {
    //Barの中で、Controller経由でモデルにアクセスできる
    //$this->Controller->User
    $result = $this->Bar->doSomething();
  }
}

初期化パラメータを変えてテストしたい場合は _createController メソッドを使って直接 Controller を生成します。

class FooTestCase extends ComponentTestCase
{
  function testAdd() {
    $controller = $this->_createController(array('Foo' => array('c' => 'd')));
    $controller->set(array('var1' => 1, 'var2' => 2));
 
    //$this->{コンポーネント名} でアクセスすることはできない
    $this->assertEqual(3, $controller->Foo->add());
  }
}

デフォルトの初期化は startTest メソッドの中で行われるため、オーバーライドすることで柔軟な設定が可能です。 例えばメソッド名に _disableAutoLoad が入っているときだけ自動処理を停止する、といったことができます。

class FooTestCase extends ComponentTestCase
{
  var $defaultConfig = array('Foo' => array('a' => 'b'));
 
  function startTest($method) {
    if(preg_match('/_disableAutoLoad/', $method)) {
      $this->components = array();
    } else {
      $this->components = $this->defaultConfig;
    }
    parent::startTest($method);
  }
 
  function testAdd() {
    $this->Foo;
  }
 
  function testAdd2_disableAutoLoad() {
    $c = $this->_createController(array('Foo' => array('c' => 'd')));
  }
}

テストに使用するコントローラのクラスを切り替えることもできます。

class BarTestCase_TestController extends Controller
{
   //独自のテスト用ロジックを実装する...
}
 
class BarTestCase extends ComponentTestCase
{
  var $controllerClassName = 'BarTestCase_TestController';
}

TestCase内でのコントローラの初期化については Testing CakePHP Controllers the hard way | Mark Story を参考にしました。

動的にコンポーネントを読み込むコンポーネント

CakePHP1.3で動的にコンポーネントを読み込むためのコンポーネント DynamicComponent を作成しました。

cakephp-dynamic-component

注意: Cake本来の機能を超える機能を提供するため、Cakeコアのprivateなメソッドやプロパティにアクセスしています。Cakeのバージョンが上がると使用できなくなる可能性があります。十分ご注意ください。

このコンポーネントを使うと次のようなことが可能になります:

  • admin プレフィクスが付いているときだけ Auth コンポーネントを読み込む
  • debug > 0 のときだけ DebugKit を読み込む

読み込みはコントローラの _initialize メソッドで loadComponents メソッドを使って行います。

class AppController extends Controller
{
  var $components = array('DynamicComponent.Dynamic');
 
  function _initialize() {
    if(!empty($this->params["prefix"]) && $this->params["prefix"] == 'admin') {
      $this->Dynamic->loadComponents('Auth');
    }
    if(Configure::read('debug') > 0) {
      $this->Dynamic->loadComponents('DebugKit.Toolbar');
    }
  }
}

この方法で読み込んだコンポーネントの initialize メソッドからは、さらに別のコンポーネントを読み込むことができます。そのためコントローラから直接 Auth や DebugKit を読み込むよりは、状況に合わせた専用のコンポーネントを用意して、その中で Auth や DebugKit の読み込み・初期化を行うことをお勧めします。以下、Admin と Debug という2つのコンポーネントを作っています。

// app/controllers/components/admin.php
class AdminComponent extends Object
{
  function initialize($controller) {
    $controller->Dynamic->loadComponents('Auth');
 
    //Authコンポーネントのセットアップ
    $controller->Auth->loginAction = array(/*...*/);
  }
}
// app/controllers/components/debug.php
class DebugComponent extends Object
{
  function initialize($controller) {
    $controller->Dynamic->loadComponents('DebugKit.Toolbar');
 
    //その他debugに有用な設定など
    $controller->set(...);
  }
}

また prefix 名に応じた読み込みを行うオプションがあらかじめ用意されているので、コントローラの prefix 判定コードは省略できます。最終的に _initialize メソッドは次のようになります。

class AppController extends Controller
{
  var $components = array('DynamicComponent.Dynamic'
                          => array('prefix' => true));
 
  function _initialize() {
    if(Configure::read('debug') > 0) {
      $this->Dynamic->loadComponents('Debug');
    }
  }
}

CakePHP 1.3RC1の新機能:プラグインdriver

せっかく私のpatchが採用されたので、ブログにも書いておきます。

CakePHP 1.3からはプラグイン内の DataSource が使えるようになりますが、1.3RC1からは DataSource の driver もプラグインから読み込めるようになりました。

たとえばGithubのCakePHP datasources pluginでは、Cakeコアに含まれない様々なデータベースに対するDboドライバが提供されていますが、RC1ではこれを次のようにして読み込むことができます。

class DATABASE_CONFIG {
  var $sqlite3 = array('driver' => 'Datasources.DboSqlite3',
                       ...);
 
}

また driver を使用する DataSource をプラグインとして提供することも可能です。拙作のKeyValueSourceは次のように使用できます。

class DATABASE_CONFIG {
  var $memcache = array('datasource' => 'KeyValueStore.KeyValueSource',
                        'driver' => 'KeyValueStore.KeyValueMemcache',
                        ...);
}

プラグインが読み込む driver を app/models/datasources 内に作ることもできます。たとえば memcache ドライバを拡張した my_memcache ドライバを作った場合、次のように読み込みます。

// app/models/datasources/key_value/key_value_my_memcache.php
 
class DATABASE_CONFIG {
  var $memcache = array('datasource' => 'KeyValueStore.KeyValueSource',
                        'driver' => 'my_memcache',
                        ...);
}

CakePHPでSchemalessなデータを扱う

拙作のKeyValueSource(key-valueストアのためのDataSource)で、スキーマ・レスなデータを保存できるようにしてみました。

cakephp-key-value-source – GitHub
(このプロジェクトは実用より実験を重視しているので、頻繁に仕様が変わります)

モデルクラスで $looseSchema というプロパティを設定すると、プライマリキーidを除いて、どんな構造のデータでも保存できるようになります。

class SchemalessUser extends AppModel {
  var $useDbConfig = 'memcache';
  var $looseSchema = true;
}
 
// in Controllers 
$SchemalessUser->save(array('id' => 1234,
                            'name' => 'John Smith',
                            'hobby' => array('baseball', 'soccer')));
$SchemalessUser->save(array('id' => 1235,
                            'firstname' => 'John',
                            'lasttname' => 'Smith',
                            'age' => 20));

saveメソッドの fieldList オプションでキーを制限することができます。

$SchemalessUser->save(array('id' => 1234,
                            'name' => 'John Smith',
                            'hobby' => array('baseball', 'soccer')),
                      array('fieldList' => array('id', 'name')));
 
// hobby は保存されない
// array('id' => 1234, 'name' => 'John Smith')
$ret = $SchemalessUser->read(null, 1234);

$looseSchema プロパティで一部のスキーマを明示的に設定しておくこともできます。下の例ではプライマリキーを key に変更し、更新日時を入力するために updated フィールドを定義しています。

class LooseSchemaUser extends AppModel {
  var $useDbConfig = 'memcache';
  var $primaryKey = 'key';
  var $looseSchema = array('key' => array('type' => 'string', 'length' => 255),
                           'updated' => array('type' => 'datetime'));
}
 
// in Controllers 
$LooseSchemaUser->save(array('key' => 1234,
                             'name' => 'John Smith'));
 
// array('key' => 1234, 'name' => 'John Smith', 'updated' => '2010-02-07 22:23:13')
$ret = $LooseSchemaUser->read(null, 1234);

$looseSchemaを設定せず、$_schema を設定すれば通常の動作になります。

class StrictUser extends AppModel {
  var $useDbConfig = 'memcache';
  var $_schema = array('id' => array('type' => 'string', 'length' => 255),
                       'name' => array('type' => 'string'));
}
 
// in Controllers 
$StrictUser->save(array('id' => 1234,
                        'name' => 'John Smith',
                        'hobby' => array('baseball', 'soccer')));
 
// hobby は保存されない
// array('id' => 1234, 'name' => 'John Smith')
$ret = $StrictUser->read(null, 1234);

裏側では KeyValueLooseSchemaBehavior というBehaviorを使って、$_schema に存在しないデータを一旦別のフィールドに退避しておき、DataSource の中で取り出しています。退避用のフィールドはデフォルトでは _schemaless_data という名前で、DataSource の describe メソッドの中で組み込まれます。さらに(ちょっと反則気味ですが) Behavior 自体も describe メソッドの中で $actsAs に設定することで、特別な設定なしで動くようになっています。

自動設定される部分を書き下すと、おおむね次のようになります。

class User extends AppModel {
  var $actsAs = array('KeyValueLooseSchema' => array('schemalessField' => '_schemaless_data'));
  var $_schema = array('id' => array('type' => 'string', 'length' => 255),
                        '_schemaless_data' => array('type' => 'schemaless'));
}

KeyValueLooseSchemaBehavior の実装は汎用的になっているので、他の DataSource にほとんどそのまま持っていけるはずです。

CakePHP用Cicindelaライブラリ

CakePHP から レコメンデーションエンジン Cicindela にアクセスするためのライブラリを公開しました。

http://github.com/tkyk/cakephp-cicindela

使い方はREADMEを読んでください。Cicindela本体のWeb APIとほとんど同じ構造なので、特に迷うことはないだろうと思います。

ちなみに Configure::read(‘debug’) > 1 で実行すると、DboSourceと同じようにリクエスト情報が一覧表示されます。デバッグに便利です。こんな感じ:

(cicindela) 2 request(s)
NrURLStatusTook (ms)
1http://host/cicindela/recommend?set=pick&op=for_item&item_id=RubyHTTP/1.1 200 OK2887
2http://host/cicindela/recommend?set=pick&op=for_item&item_id=PHPHTTP/1.1 200 OK1346

….
以下、このライブラリを作りながら考えたこと。

このライブラリはDataSource + Behaviorという2クラス構成になっている。最初はDataSourceだけでできるかと思っていたのだが、無理だった。現在のCakeのModelクラスは、DataSourceとDboSourceの関係で言うなら”DboModel”であって、非DboのDataSourceを扱うには適していない。

現在のModelが”DboModel”であるように、XXXSourceに対してはXXXModelが存在することが望ましい。CakePHP 1.3からはDataSourceがプラグイン化できるようなので、同時にModelのベースクラスを提供するようにすれば、ある程度は改善されると思う。ただそれでも結局(プラグインの)ユーザがいちいちクラスの継承関係に気を使わなければいけない点が煩わしい。理想を言うなら、1.非常に簡素なBaseModelクラスを用意する、2.DataSourceにモデル初期化用コールバックを設ける、3.初期化用コールバックにおいてBehaviorをattachする、DataSource固有の機能はBehaviorに実装する……という構成にできれば良いのではないか。

Behavior の適用範囲を広げる $mapMethods

(CakePHP 1.2.5)
ビヘイビアに定義されたメソッドは、モデル自身のメソッドであるかのように呼び出すことができます。

class FooBehavior extends ModelBehavior {
  function foo($model){ /* ... */ }
}
 
class Bar extends AppModel {
  var $actsAs = array('Foo');
}
 
// in Controllers
$this->Bar->foo();

しかしこの方法では Dynamic Finder のように動的に名前が変化するメソッドは実装できません。

ModelBehavior クラスの $mapMethods プロパティを使用すると、この制約を乗り越えることができます。$mapMethods は配列で、次のような構造をしています:

array( '/メソッド名に対する正規表現/' => '実際に呼び出されるメソッド'  )

例えば '/^list/' => '_genericList' という設定を行った場合、listから始まる全てのメソッド呼び出しが、ビヘイビアの _genericList メソッドの呼び出しに変換されます。このとき _genericList メソッドの引数には元のメソッド名と引数が渡されます。ちょうど __call によるメソッドのオーバーロードと同じです。

この機能を活用して、あるカラムの値を配列として取得するための listColumn メソッドを作ってみます(Column はカラム名)。このメソッドは ListingBehavior に実装することにしましょう。

class ListingBehavior extends ModelBehavior {
  var $mapMethods = array('/^list[a-zA-z0-9]+$/' => '_listColumn');
 
  function _listColumn($model, $methodName, $query=array()) {
    $column = substr($methodName, 4);
    $query = am($query, array('fields' => array($model->alias.".".$column)));
    $arr = $model->find('all', $query);
    if(is_array($arr)) {
      return Set::extract("/{$model->alias}/{$column}", $arr);
    }
    return $arr;
  }
}

モデルにこのビヘイビアを組み込むと、任意のカラムに対して listXXX メソッドを呼び出すことができるようになります。

class User extends AppModel {
  var $actsAs = array('Listing');
}
 
// in Controllers
$this->User->listId();
$this->User->listUsername(array('conditions' => 'active = 1'));
$this->User->listGroup_id();

メソッド名は小文字に変換されて渡されるので(これが仕様なのかバグなのか微妙なところです)、残念ながら Inflector::underscore を使うことはできません。

key-valueストア用DataSource(CakePHP1.2)

key-valueストア用の DataSource サンプルをgithubで公開しておきます。

cakephp-key-value-source – ver.0.1

以下の説明はバージョン0.1を対象としたものです

以下のクラスが含まれています:

KeyValueSource
抽象ベースクラス。サブクラスでは最低限 get/set/del/count 4つのメソッドを実装する
JsonFileSource
jsonエンコードしてローカルファイルに保存する実装例
MemcacheSource
pecl-memcache を用いる実装例

CakePHPバージョン1.2の DataSource の仕組みはまだまだ未成熟で、あまり突っ込んだ説明は書きにくいので、詳しく知りたい方はコードを読んでください。スキーマ・レスな(スキーマの設定を DataSource側ではなく Model 側で行う)Sourceの例としては最小限の構成になっているはずです。

使用例

database.php

class DATABASE_CONFIG
{
  var $json = array('datasource' => 'json_file',
                    'root' => TMP,
                    'path' => '/json-encoded');
 
  var $memcache = array('datasource' => 'memcache',
                        'host' => 'localhost',
                        'port' => 11211,
                        'default_expire' => 3600,
                        'default_flag' => 0);
}

モデル

class Foo extends AppModel
{
  //どちらを使ったとしても、基本的な機能を使う限り、全く同じ動作になる
  var $useDbConfig = 'json';
  //var $useDbConfig = 'memcache';
 
  /*
    スキーマの設定
    id には length 必須(特に意味がなくても)
  */
  var $_schema = array('id' => array('type' => 'integer', 'length' => 4),
                       'bar_count' => array('type' => 'integer'),
                       'baz_count' => array('type' => 'integer')
                       /* ... */);
}

コントローラ

//保存するデータの構造などはDBを使う場合と同じ
$data = array('Foo' => array('id' => 1,
                             'bar_count' => 1000,
                             /* ... */));
 
//保存
$this->Foo->save($data);
 
//読み出し
$data = $this->Foo->read(null, 1);
 
//戻り値の構造もDBを使う場合と同じ
$barCount = $data['Foo']['bar_count'];

この程度の使い方だと cache と大して変わらないので、バックエンド固有の機能も少しだけ実装してみました。JsonFileSource は decode オプションを指定することでJSONからのデコード方法を指定できます。false を指定すればJSON(文字列)のまま取り出すこともできます。

//JSONのオブジェクトをPHPのオブジェクトとしてデコード(デフォルトでは連想配列)
$obj = $this->Foo->find('first',
                        array('conditions' => array('Foo.id' => 1),
                              'decode' => 'object'));
$bar = $obj['Foo']->bar_count;
 
//JSON文字列のまま取り出す
$data = $this->Foo->find('first',
                         array('conditions' => array('Foo.id' => 1),
                               'decode' => false));
$json = $data['Foo'];

MemcacheSource はモデルの expires プロパティでデータの有効期限を設定できます。

//期限無制限に
$this->Foo->expire = 0;
$this->Foo->save($data);

参考:

簡単DataSourceの作り方
countまわりの実装など参考にしました。こちらではbakeで動かすことが主眼となっていますが、私のサンプルはbakeでは動かないはずです(bake自体使わないので)

CakePHP 1.2 prefix付きURL生成時の注意点

※CakePHP バージョン 1.2限定の話題です。

Router::url によるURLの生成時に、prefix パラメータは考慮されない。ありがちな例として携帯用URLを作るために次のような route を追加した場合、

Router::connect('/m/:controller/:action/*',
                array('prefix' => 'mobile'));

URL生成時には prefix パラメータの有無によらず、この route がマッチする。各種ヘルパー($html->link | $paginator | $form->create)でPC用のURLに意図しないプレフィクスが付いてしまう、という問題の原因はこれである。

// どちらも /m/foo/bar になってしまう!
$url->link('link', array('controller' => 'foo', 'action' => 'bar', 'prefix' => 'mobile'));
$url->link('link', array('controller' => 'foo', 'action' => 'bar'));

この問題を回避するためのイディオムとして、prefix パラメータの他に [prefix名] => true というパラメータを設定する。

Router::connect('/m/:controller/:action/*',
                array('prefix' => 'mobile',
                      'mobile' => true));

URL生成時にはこちらのパラメータを使う。

// こちらは /m/foo/bar
$url->link('link', array('controller' => 'foo', 'action' => 'bar', 'mobile' => true));
 
// こちらは /foo/bar
$url->link('link', array('controller' => 'foo', 'action' => 'bar'));

参考:

3.4.5.5 プリフィックスルーティング(Prefix Routing) :: The Cookbook
現在は正しいコード例が示されている。
“Prefix利用時のPaginatorHelperが吐くURLが正しく表示されない” フォーラム – CakePHP Users in Japan:
cakephp.jpのフォーラムにて。検索パラメータが引き継げないのはおそらく connectNamed が設定されていないから?

なお Routing.admin によるルーティングは純粋なプレフィクスルーティングではなく、Router の中で特別扱いされているのでこの問題は生じない。また CakePHP 1.3 ではプレフィクスルーティングの機能は大きく拡張されるため、最終的にどのようになるのかは分からない(コードを斜め読みした限りでは、個々の route に個別に設定する必要は無くなり、Configure::write(‘Routing.prefixes’, array(…)); で一括指定できるようになるらしい。ただ少なくとも 1.3.0-alpha においては、1.2同様の prefix も使えるようだ)。

追記:

[prefix名] => true を設定してもうまくプレフィクスが付かない場合は、route 側にパラメータが足りない可能性が高い。最もありがちなのは名前付きパラメータを使用する場合。

// こんなURLを作りたいが => /m/foo/bar/type:99 
// こうなってしまう => /foo/bar/mobile:1/type:99
$html->link('link', array('controller' => 'foo', 'action' => 'bar', 'mobile' => true, 'type' => 99));

こういうときは Router::connectNamed を使って routes.php の中で宣言しておく必要がある。

Router::connectNamed(array('type'));

Router::connectNamed についての説明はAPIドキュメントが最も詳しい。ちなみに page や sort といった paginator 関連のパラメータはデフォルトで設定されるので改めて設定する必要はない(だからこそ気が付きにくいとも言えるが)。