Edited at
FuelPHPDay 19

EAVコンテナでカスタムフィールドっぽいことをする

More than 3 years have passed since last update.

FuelPHP Advent Calendar 2015 の19日目を担当させていただきます。

よろしくお願いします。

何を書こうか迷いましたが、Wordpressとかでよくあるカスタムフィールドっぽいものを実装する方法を書いてみようかと思います。


EAVコンテナとは

EAVコンテナ / FuelPHP 1.7 Documentation


「エンティティ-アトリビュート-バリュー (Entity-attribute-value) モデル (EAV) は、潜在的には大変に多くの属性(プロパティやパラメータ)を記述できることが必要だが、 通常は特定のエンティティに適用される数はさほど多くない場合に適したデータモデルです。 数学ではこのモデルは疎行列として知られています。 EAVはまた、オブジェクト-属性-値モデル、垂直データベースモデル、オープンスキーマなどと呼ばれることもあります。」

出展: ウィキペディア(英語).


とのことです。よく分かりませんね。

要は、可変するフィールドをいちいちDB定義するのは面倒だから、key-value型っぽく保存してしまおうということだと思います。

WordpressやMTのカスタムフィールドもこんな形で実装されていたかと思います。使い所を間違わなければ非常に便利です。


実装

それでは、早速実装してみたいと思うのですが、実装方法は本家のドキュメントに丁寧に書いていますので、今回はもう少し実用っぽい感じで作っていこうと思います。


複数モデルに対応する

ひとつのTableで複数のEAVモデルを一緒に管理してみます。(実用的かどうかの判断は・・・お任せします)

構成はこんな感じです。

classes

|- model
|- entry.php
|- eav.php <-- EAV
|- entry
|- meta.php <-- EAVを継承
|- geometry.php <-- EAVを継承

記事(Model_Entry)に複数のメタ情報(Model_Entry_Meta)と複数の位置情報(Model_Entry_Geometry)がぶら下がっている感じです。

Model_Entry_MetaModel_Entry_GeometryModel_Envを継承しています。


Migration


fuel/app/migrations/001_create_eavs.php

namespace Fuel\Migrations;

class Create_eavs
{
public function up()
{
\DBUtil::create_table('eavs', array(
'id' => array('constraint' => 11, 'type' => 'int', 'auto_increment' => true, 'unsigned' => true),
'object_class' => array('constraint' => 255, 'type' => 'varchar'),
'object_id' => array('constraint' => 255, 'type' => 'varchar'),
'key' => array('constraint' => 255, 'type' => 'varchar'),
'value' => array('constraint' => 255, 'type' => 'varchar', 'null' => true),

), array('id'));
}

public function down()
{
\DBUtil::drop_table('eavs');
}
}



Model


fuel/app/classes/model/eav.php

class Model_Eav extends \Orm\Model

{
protected static $_properties = array(
'id',
'object_class' => array('default'=>''),
'object_id',
'key',
'value',
);

protected static $_observers = array(
);

protected static $_table_name = 'eavs';

/**
* Overrides the query method to allow soft delete items to be filtered out.
*/

public static function query($options = array())
{
$query = \Orm\Query::forge(get_called_class(), static::connection(), $options);

$query->where( array( 'object_class' => static::$_properties['object_class']['default'] ) );

return $query;
}
}



fuel/app/classes/model/entry/meta.php

class Model_Entry_Meta extends Model_Eav

{
protected static $_properties = array(
'id',
'object_class' => array('default' => 'entry_meta'),
'object_id',
'key',
'value',
);

public static function validate_relation($val,$id)
{
$val->add_field('meta.'.$id.'.key', __('model.entry_meta.value'), 'required');
return $val;
}

}



fuel/app/classes/model/entry/geometry.php

class Model_Entry_Geometry extends Model_Eav

{
protected static $_properties = array(
'id',
'object_class' => array('default' => 'entry_geometry'),
'object_id',
'key',
'value',
);

public static function validate_relation($val,$id)
{
$val->add_field('geometry.'.$id.'.key', __('model.entry_geometry.value'), 'required');
return $val;
}

}



リレーション


fuel/app/classes/model/entry.php

class Model_Entry extends Model

{
protected static $_properties = array(
'id',
);

protected static $_belongs_to = array(
);

protected static $_has_many= array(
'metas' => array(
'model_to' => 'Model_Entry_Meta',
'key_from' => 'id',
'key_to' => 'object_id',
'cascade_save' => true,
'cascade_delete' => true,
'conditions' => array(
'where' => array(
'object_class' => 'entry_meta',
)
)
),
'geometries' => array(
'model_to' => 'Model_Entry_Geometry',
'key_from' => 'id',
'key_to' => 'object_id',
'cascade_save' => true,
'cascade_delete' => true,
'conditions' => array(
'where' => array(
'object_class' => 'entry_geometry',
)
)
),
);

public static function validate($factory)
{
$val = Validation::forge($factory);

if( Input::post('meta') ){
foreach( Input::post('meta') as $id => $p ){
$val = Model_Entry_Meta::validate_relation($val,$id);
}
}
if( Input::post('geometry') ){
foreach( Input::post('geometry') as $id => $p ){
$val = Model_Entry_Geometry::validate_relation($val,$id);
}
}

return $val;
}



確認

$e = Model_Entry::forge()

$e->metas
$e->geometries


検索

検索時は、


fuel/app/classes/controller/entry.php

public function action_index()

{
$options = array( 'where' => array() );
if( $p = Input::param('meta',false) ){
$options = \Arr::merge($options,array(
'related' => array(
'metas' => array(
'where' => array(
array('key' ,'keywords'),
array('value','LIKE',"%$p%"),
))));}

$entries = Model_Entry::find($options);
}


としたり、ORとかANDとか駆使していけばなんとなく動くと思います。


参照

EAVコンテナは本当は本家ドキュメントにもあるように、こんな感じでkeyを直接指定して値を参照できるのがよいところなので、こうして使ってもよいと思います。


    // EAV コンテナはこのように定義します

protected static $_eav = array(
'statistics' => array( // we use the statistics relation to store the EAV data
'attribute' => 'key', // the key column in the related table contains the attribute
'value' => 'value', // the value column in the related table contains the value
)
);
// これで属性を直接得ることができるようになりました
echo $mr->Temperature; // '38.4'
echo $mr->Headache; // 'yes'

それぞれ違うテーブルにリンクする複数の EAV を定義することもできます。 その場合、属性が一致するものが見つかるまで、定義された順序ですべてのコンテナを検索します。 もしプロパティがどこにも定義されていない場合は、 モデルに対する通常のプロパティと同様な処理をします。つまり例外が投げられるということです。


ただ、今回の場合、

echo $entry->keywords; //'blog,food,bar' <-- Model_Entry_Metaを参照

echo $entry->shop; //'43.00...,131.00..' <-- Model_Entry_Geometryを参照

となって見通しがあまりよくないので、ここは少し考えものです。。


新規作成・更新


現時点では、関連テーブルのEAVコンテナに新しい属性を追加する時にこのアクセスメソッドは使えません。 その場合は古いやり方を使って下さい。 つまりこのようにします。

$mr->statistics[] = Statistics::forge($newdata);


とのことなので、ここはそのまま、

$entry->metas[] = Model_Entry_Meta::forge($options);

$entry->geometries[] = Model_Entry_Geometry::forge($options);

とすればいいと思います。


何に使うか

問題はそれですね。

基本的には、「何にでも使えるのでとりあえず001_でmigrationしとけばいい」くらいの感じです。

とくに、WEBサイトの場合、サイトのメタ情報とかは多種多様で、後から後から要望が出て来るものだと思います。

その度にいちいちフィールド追加するよりも、こんな感じで情報を持っておければ便利に使えます。

また、やろうと思えばですが、WordpressのカスタムフィールドのようにEAVで複雑な検索をさせることもできると思うので、CMS的な用途にも使えるかと思います。