CakePHP + mroonga

麺処まつば副店長です。
あけましておめでとうございます。(遅)
年末年始から今まで、当店はモンハン一色でございます。
店長も副店長も働きもせずモンハンばっかりやっています。(半ば開き直りながら)

店長が遊んでいる=自分も遊んでいい。とか思ってダラダラしていたら
ついに(自分だって遊んでばっかりの)店長に「そろそろ書け」とか言われました…。

なんかネタあったっけな……と(遊んでばっかりの)記憶を掘り起こしてみたところ、
先日別件で CakePHP全文検索したいから mroonga を動かせるようにしといて。
というお仕事があったのです。
CakePHP は、mroonga に対応してないので
オーバーライドして無理矢理対応させた時の話でもしようと思います。

まず、CakePHP?mroonga?なにそれ美味しいの?って方はコチラへ。
CakePHPhttp://cakephp.jp/
mroonga:http://mroonga.github.com/

<使用環境>
CakePHP:2.0
mroonga:1.2
mysql:5.5
CentOS 5.7

今回書くのは下記3つです。
1.cake schema create
2.cake schema generate
3.find

では書いてみます。

cake schema create に対応する。

mroonga な全文検索に対応したテーブルを作成するには、
通常下記のような SQL でテーブルを作成します。

1  CREATE TABLE diaries (
2    id INT(10) NOT NULL AUTO_INCREMENT,
3    title VARCHAR(255) NOT NULL,
4    content VARCHAR(255) NOT NULL,
5    FULLTEXT INDEX (title),
6    FULLTEXT INDEX (content),
7    PRIMARY KEY (`id`)
8  ) ENGINE = mroonga COMMENT = 'engine "innodb"'

<5、6行目>
title カラムと content カラムを FULLTEXT INDEX に対応させています。
<8行目>
エンジンに mroonga を使用する宣言をしています。
さらに、今回はラッパーモードを使用するので、
テーブルのコメントに「engine "innodb"」を入れる必要があります。

コレを実現するために、こういう schema.php を書きます。

<?php
 1 public $diaries = array(
 2   'id'      => array('type' => 'integer', 'null' => false, 'default' => NULL, 'key' => 'primary', 'collate' => NULL, 'comment' => ''),
 3   'title'   => array('type' => 'string', 'null' => true, 'default' => NULL, 'key' => 'index', 'collate' => 'utf8_general_ci', 'comment' => '', 'charset' => 'utf8'),
 4   'content' => array('type' => 'string', 'null' => true, 'default' => NULL, 'key' => 'index', 'collate' => 'utf8_general_ci', 'comment' => '', 'charset' => 'utf8'),
 5   'indexes' => array(
 6     'PRIMARY' => array('column' => 'id', 'unique' => 1), 
 7     'title'   => array('column' => 'title', 'unique' => 0, 'index_type' => 'FULLTEXT'),
 8     'content' => array('column' => 'content', 'unique' => 0, 'index_type' => 'FULLTEXT'),
 9   ), 
10   'tableParameters' => array('charset' => 'utf8', 'collate' => 'utf8_general_ci', 'engine' => 'mroonga', 'comment' => 'engine "innodb"')
11 );

<7、8行目>
FULLTEXT INDEX (title),」と「FULLTEXT INDEX (content),」に該当する行です。
<10行目>
「ENGINE = mroonga COMMENT = 'engine "innodb"'」に該当する行です。


さらにこれを、schema createで実行できるようにオーバーライドします。

まずは、ラッパーモード使用の宣言をするため、テーブルにコメントが付けられるようにします。
対象ファイル:project_name/lib/Cake/Model/Datasource/Database/Mysql.php

<?php
1 public $tableParameters = array(
2         'charset' => array('value' => 'DEFAULT CHARSET', 'quote' => false, 'join' => '=', 'column' => 'charset'),
3         'collate' => array('value' => 'COLLATE', 'quote' => false, 'join' => '=', 'column' => 'Collation'),
4         'engine' => array('value' => 'ENGINE', 'quote' => false, 'join' => '=', 'column' => 'Engine')
5         // kokokara ------------------------------------------
6         ,'comment' => array('value' => 'COMMENT', 'quote' => true, 'join' => '=', 'column' => 'Comment')
7         // kokomade ------------------------------------------
);    

6行目を追加します。
これで schema.php 中にテーブルの「comment」があった場合の処理を追加します。


続いて、FULLTEXT INDEX宣言できるようにします。
対象ファイル:project_name/app/Model/Datasource/DboSource.php
buildIndex 関数をオーバーライドします。10〜14行目追記。

<?php
 1    public function buildIndex($indexes, $table = null) {
 2      $join = array();
 3      foreach ($indexes as $name => $value) {
 4        $out = '';
 5        if ($name === 'PRIMARY') {
 6          $out .= 'PRIMARY ';
 7          $name = null;
 8        } else {
 9          // kokokara -------------------------------------------
10          if(isset($value['index_type']) && $value['index_type'] == 'FULLTEXT' ){
11            $out = 'FULLTEXT INDEX('.$value['column'].')';
12            $join[] = $out;
13            continue;
14          }
15          // kokomade -------------------------------------------
16          if (!empty($value['unique'])) {
17             $out .= 'UNIQUE ';
18          }
19          $name = $this->startQuote . $name . $this->endQuote;
20        }
          …略…

この状態で、先程の schema.php を走らせてみます。

$ app/Console/cake schema create

対象テーブルがどうなったかを確認します。

SHOW CREATE TABLE diaries;

  CREATE TABLE `diaries` (
    `id` int(10) NOT NULL AUTO_INCREMENT,
    `title` varchar(255) NOT NULL,
    `content` varchar(255) NOT NULL,
    PRIMARY KEY (`id`),
    FULLTEXT KEY `title` (`title`),
    FULLTEXT KEY `content` (`content`)
  ) ENGINE=mroonga DEFAULT CHARSET=utf8

SHOW INDEX FROM diaries;
  +---------+------------+-------------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+
  | Table   | Non_unique | Key_name    | Seq_in_index | Column_name | Collation | Cardinality | Sub_part | Packed | Null | Index_type | Comment | Index_comment |
  +---------+------------+-------------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+
  | diaries |          0 | PRIMARY     |            1 | id          | A         |           0 |     NULL | NULL   |      | BTREE      |         |               |
  | diaries |          1 | title       |            1 | title       | NULL      |        NULL |     NULL | NULL   |      | FULLTEXT   |         |               |
  | diaries |          1 | content     |            1 | content     | NULL      |        NULL |     NULL | NULL   |      | FULLTEXT   |         |               |
  +---------+------------+-------------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+

できたっぽいです。

cake schema generate に対応する

上記までで、schema createはできるようになったので、その逆です。
schema generate で schema.php を出力できるようにします。

→(2012/03/21)副店長の記憶が非常に曖昧なため一旦削除します。
→(2012/04/06)副店長の記憶は正しかったです。戻します。

対象ファイル:project_name/lib/Cake/Model/Datasource/Database/Mysql.php

<?php
 1      public function index($model) {
 2        $index = array();
 3        $table = $this->fullTableName($model);
 4        $old = version_compare($this->getVersion(), '4.1', '<=');
 5        if ($table) {
 6          $indices = $this->_execute('SHOW INDEX FROM ' . $table);
 7          while ($idx = $indices->fetch()) {
 8            if ($old) {
 9              $idx = (object) current((array)$idx);
10            }
11            // kokokara -------------------------------------------
12            if($idx->Index_type == "FULLTEXT"){
13                    $col = array();
14                    $index[$idx->Key_name]['column'] = $idx->Column_name;
15                    $index[$idx->Key_name]['unique'] = intval($idx->Non_unique == 0);
16                    $index[$idx->Key_name]['index_type'] = "FULLTEXT";
17                    continue;
18            }
19            // kokomade -------------------------------------------
20            if (!isset($index[$idx->Key_name]['column'])) {
22              $col = array();
2324      }

index 関数をオーバーライドします。
12〜18行目を追加。INDEXの定義に「FULLTEXT」があったら、
その内容を出力するように書き換えました。

この状態で、schema generate をすると、先程書いたような記述が schema.php に出力されます。

CakePHP から find

mroonga で全文検索する場合は、下記のような SQL を実行します。

SELECT * FROM diaries WHERE MATCH(content) AGAINST("hoge");

このようなのを CakePHP から実行します。
特にオーバーライドなど必要なく、これで検索できます。

<?php
  $this->Diary->find('all', array(
    'conditions' => array('MATCH(content) AGAINST(?)' => array("hoge"))
  ));

SQL直ならこう。

<?php
  $this->Diary->getDatasource()->fetchAll(
    'SELECT * FROM diaries WHERE MATCH(content) AGAINST(?)',
     array("hoge")
  );

こんな感じで、なんとかそれっぽく動くようになりました。
間違えてるとか、他に良い方法あれば、どなたか教えてください
m(_ _)m""


それから… mroonga のパーサを変更する場合、
インデックスのコメントにパーサを指定することが可能なんですがそれは対応してません。
もう一息なのは分かっているのですが力尽きて、my.cnf でデフォルト指定しました(笑)
誰か書いてください(ちらっちらっ>店長)

<追記>
あとこういうネタもありますが、需要があれば書きます
・mroonga インストール時、mysql 衝突事件。(CentOS5)
・cake testsuite 対応