RitoLabo

CakePHP3のアソシエーションでリレーション(JOIN)を行いデータを取得する

  • 公開:
  • 更新:
  • カテゴリ: PHP CakePHP
  • タグ: PHP,CakePHP,3.5,Model,Table,Associations

CakePHP3を使ってWEBアプリケーションを構築する際に避けて通れないのが「アソシエーション」という機能です。

この機能はモデル自体をつなぐ(関連付ける)事でテーブルのリレーションを簡単に行えるようにするものですが、要するにリレーションです。

ただしPHPの初心者、もしくはMySQLをあまり書いた経験の無い人、そしてフレームワーク自体に慣れていない人には微妙にとっつきにくさを感じます。

アソシエーションそれ自体の機能は覚えてしまえばなんてことはなく、慣れれば逆に便利だと感じますが、この機能を習得しようと思った時に最も障壁となるのは機能自体の使い方というよりはそれ自体の考え方だと個人的には感じます。

しかし、Laravelなど他のPHPフレームワークや、MySQLをよく使う人であれば、少し理解すればなんてことのない部分でもあるので、今回はアソシエーションに関してのイメージ、考え方も含めて使い方を解説します。

アジェンダ
  1. 開発環境
  2. アソシエーションについて
  3. アソシエーションの定義
  4. アソシエーションを使ったデータ取得
  5. hasOne
  6. hasMany
  7. belongsTo
  8. belongsToMany

開発環境

今回の開発環境は以下の通りです。

  • Linux CentOS 7
  • Apache 2.4
  • PHP 7.1
  • CakePHP 3.5

CakePHPのバージョンについては、3系であれば同一手順で進めていけます。

CakePHPのルートディレクトリを「cakephp/」としています。

アソシエーションについて

CakePHP3のアソシエーションは4種類あり、それぞれ「hasOne」「hasMany」「belongsTo」「belongdToMany」となっています。

アソシエーションの定義

アソシエーションを定義する場合の基本的な記法をまとめます。

アソシエーションは、テーブルオブジェクト(モデルのテーブルクラス)の initialize()メソッドの中で指定していきます。

class UsersTable extends Table
{
public function initialize(array $config)
{
$this->hasOne('Roles');
}

配列でパラメータをセットする事もできます。

class UsersTable extends Table
{
public function initialize(array $config)
{
$this->hasOne('Roles', [
'joinType' => 'INNER',
'foreignKey' => 'role',
'bindingKey' => 'role',
'propertyName' => 'roles'
]);
}

アソシエーション種別によって設定可能な項目が多少違いますが、よく使うものやなんとなくこんがらがりそうなものだけいくつか抜粋しました。

joinType
SQLでJOINする際の種別を指定します。「INNER」「LEFT」「RIGHT」が指定されますが、指定の無い場合はデフォルトとして LEFT で JOIN されます。
foreignKey
リレーションする「」のテーブルの、紐づけるカラム。
bindingKey
リレーションする「」のテーブルの、紐づけるカラム。
propertyName
結果データとして格納される配列のプロパティ(KEY)名。指定がない場合はアソシエーション名をアンダースコア区切りの単数形にした名前がセットされる。
sort
hasManyやbelongsToManyの場合、リレーションして取得する1レコードあたりのデータが複数になるので、並び順を指定できる。

[Cookbook]アソシエーション
https://book.cakephp.org/3.0/ja/orm/associations.html

アソシエーションを使ったデータ取得

アソシエーションを定義後、コントローラからアソシエーションを用いてデータを取得する場合は、以下のようにcontainプロパティに対象のテーブルを指定します。また、TableRegistryを用いる場合は、contain()メソッドでテーブルオブジェクトを指定する事で実現します。

use Cake\ORM\TableRegistry;

class MessagesController extends AppController
{
public function index()
{
$this->paginate = [
'contain' => ['Users']
];
$messages = $this->paginate($this->Messages);

$this->set(compact('messages'));
}

public function getData()
{
// TableRegistry を使った例
$messages = TableRegistry::get('Messages');
$messages = $messages->find()
->contain(['Users'])
->all();
}

ここでのデータ取得の手法にはいろいろな小ネタがあるので、詳しくはCookbookを見てみてください。

[Cookbook]データの取り出しと結果セット
https://book.cakephp.org/3.0/ja/orm/retrieving-data-and-resultsets.html

hasOne

hasOne は「1対1」の関係のモデルをつなぎます。

基テーブルのリレーションしたいカラムに対して、つなぐ先のテーブル内の該当レコードが必ず1つである事。例えば、ユーザ情報テーブルに権限を示す「role」というカラムがあり、あるユーザのその値に対して、リレーションする先の権限マスタテーブルにはその値と結びつくもの(該当レコード)が必ず1つ(ユニーク)である。という状態になります。

-----------------------------------------
●ユーザ情報テーブル
ユーザA[ role :
3 ]の場合

●権限マスタテーブル
                  

    値   1     2   

     システム管理者 管理者 作業者
-----------------------------------------
●ユーザ情報テーブル
ユーザB[ role :
2 ]の場合

●権限マスタテーブル
              

    値   1     
   3
     システム管理者 管理者 作業者
-----------------------------------------

上記のように、対応するデータは必ず1つであるという状態になります。例えばhasOneを以下のようにモデルに定義します。

cakephp/src/Model/Table/UsersTable.php
class UsersTable extends Table
{
public function initialize(array $config)
{
parent::initialize($config);

$this->setTable('users');
$this->setDisplayField('name');
$this->setPrimaryKey('id');

$this->addBehavior('Timestamp');

$this->hasOne('Roles', [
'joinType' => 'INNER',
'foreignKey' => 'role',
'bindingKey' => 'role',
'propertyName' => 'roles'
]);
}

ユーザの一覧を取得してみます。

cakephp/src/Controller/UsersController.php
use Cake\ORM\TableRegistry;

class UsersController extends AppController
{
public function getData()
{
$users = TableRegistry::get('Users');
$users = $users->find()
->contain(['Roles'])
->all();
}

結果データは以下のようになります。

Array
(
[0] => (
[id] => 1
[name] => 'ユーザ名A'
[role] => 3
[roles] =>
(
[id] => 3
[name] => '作業者'
)
)
,
[1] => (
[id] => 2
[name] => 'ユーザ名B'
[role] => 2
[roles] =>
(
[id] => 2
[name] => '管理者'
)
)
,
),
.
.
.
)

ユーザテーブルのrole(権限値)に紐づく権限マスタテーブルの情報がリレーションされ返されます。

この時、1件のユーザデータにつき、紐づく権限マスタレコードは必ず1つである事が確認できると思います。これこそが「1対1」の関係。hasOneということになります。

hasMany

hasMany は「1対多」である関係のモデルをつなぎます。

基テーブルのリレーションしたいカラムに対して、つなぐ先のテーブル内の該当レコードが複数ある状況の事を言います。

例えば、「ユーザ情報テーブル」と「送信メッセージ履歴テーブル」があるとして、メッセージテーブルにはユーザIDに紐づけてメッセージを格納してあるとします。

この場合、ユーザテーブルのユニークなユーザIDに対して、メッセージテーブルにはそのIDに紐づく複数のレコードが存在している。という状態になります。

--------------------------------
●ユーザ情報テーブル
ユーザA [ user_id :
1 ]



●送信メッセージ履歴テーブル
user_id message
1   お元気ですか?
2   よろしくお願いします。
1   お先に失礼します。
4   会議室で待っています。
5   先に始めててください。
1   今日は直帰します。
2   残業できません。
2   わかりました、やります。
--------------------------------

上記のように、対象となるユーザIDを持つレコードが複数ある。という状態です。例えばhasManyを以下のようにモデルに定義します。

cakephp/src/Model/Table/UsersTable.php
class UsersTable extends Table
{
public function initialize(array $config)
{
parent::initialize($config);

$this->setTable('users');
$this->setDisplayField('name');
$this->setPrimaryKey('id');

$this->addBehavior('Timestamp');

$this->hasMany('Messages', [
'joinType' => 'INNER',
]);
}

ユーザの一覧を取得してみます。

cakephp/src/Controller/UsersController.php
use Cake\ORM\TableRegistry;

class UsersController extends AppController
{
public function getData()
{
$users = TableRegistry::get('Users');
$users = $users->find()
->contain(['Messages'])
->all();

}

結果データは以下のようになります。

Array
(
[0] => (
[id] => 1
[name] => 'ユーザ名A'
[role] => 3
[masseges] =>
[0] => (
[id] => 3
[user_id] => 1
[message] => 'お元気ですか?'
)
[1] => (
[id] => 5
[user_id] => 1
[message] => 'お先に失礼します。'
)
[2] => (
[id] => 8
[user_id] => 1
[message] => '今日は直帰します。'
)
),
[1] => (
[id] => 2(user_id)
[name] => 'ユーザ名B'
[role] => 2
[masseges] =>
[0] => (
[id] => 4
[user_id] => 2
[message] => 'よろしくお願いします。'
)
[1] => (
[id] => 9
[user_id] => 2
[message] => '残業できません。'
)
[2] => (
[id] => 10
[user_id] => 2
[message] => 'わかりました、やります。'
)
),
.
.
.
)

ユーザIDに紐づくメッセージ送信履歴情報がリレーションされ返されます。

この時、ユーザ1件に対してリレーションされたメッセージが複数件ある事が確認できます。これこそが「1対多」の関係。hasManyということになります。

belongsTo

belongsTo は「多対1」である関係のモデルをつなぎます。

例えば「送信メッセージ履歴テーブル」からメッセージの一覧を取得しようと思った時に、「ユーザ情報テーブル」をリレーションしておく事で、「ユーザIDに紐づけて誰が送信したメッセージまでがわかる」ようになりますが、その場合に、「紐づけるもの(ここではユーザID)はどちらが主体か」で has なのか belong なのかが決まります。この場合はユーザIDを主体としてリレーションを行っているので主体はユーザ管理テーブルとなり、メッセージテーブル側から取得しようとする場合は「belong」になります。

いわゆる、外部キー(FK = Foreign Key)を持っているテーブルからのリレーションを行う状況。になります。

ちなみに公式(cookbook)では

belongsTo アソシエーションは hasOne や hasMany の自然な補完です。つまり、他の方向からの関連データを見ることができます。

と記述があります。

--------------------------------
●送信メッセージ履歴テーブル
user_id(FK) message
1     お元気ですか?
2     よろしくお願いします。
1     お先に失礼します。
4     会議室で待っています。
5     先に始めててください。
1     今日は直帰します。
2     残業できません。
2     わかりました、やります。

●ユーザ情報テーブル
id(PK) name
1   ユーザA
2   ユーザB
3   ユーザC
4   ユーザD
5   ユーザE
6   ユーザF
7   ユーザG
--------------------------------

上記のように、ユーザIDで紐づけるが、それ自体の主体はユーザ情報テーブルのidである。=「外部キーを持つテーブルからのリレーション」という状態になります。

例えばbelongsToを以下のようにモデルに定義します。

cakephp/src/Model/Table/MessagesTable.php
class MessagesTable extends Table
{
public function initialize(array $config)
{
$this->belongsTo('Users', [
'foreignKey' => 'user_id',
'joinType' => 'INNER'
]);
}

メッセージの一覧を取得してみます。

cakephp/src/Controller/MessagesController.php
use Cake\ORM\TableRegistry;

class MessagesController extends AppController
{
public function getData()
{
$messages = TableRegistry::get('Messages');
$messages = $messages->find()
->contain(['Users'])
->all();
}

結果データは以下のようになります。

Array
(
[0] => (
[id] => 1
[user_id] => 1
[message] => 'お元気ですか?'
[user] => (
[id] => 1
[name] => 'ユーザA'
[role] => 3
)
)
,
[1] => (
[id] => 2
[user_id] => 2
[message] => 'よろしくお願いします。'
[user] => (
[id] => 2
[name] => 'ユーザB'
[role] => 2
)
)
,
[3] => (
[id] => 3
[user_id] => 1
[message] => 'お先に失礼します。'
[user] => (
[id] => 1
[name] => 'ユーザA'
[role] => 3
)
)
,
.
.
.
)

メッセージデータに、ユーザIDに紐づくユーザ情報がリレーションされ返されます。

この時、ユニークではない外部キー(FK)から主キー(PK)とのリレーションを行いデータを取得してきている事が確認できると思います。これこそが「多対1」の関係。belongsToということになります。

belongsToMany

belongsToMany は、「多対多」である関係のモデルをつなぎます。

中間テーブルを使って各々を統合するような使い方の場合に用いる事が出来ます。例えば以下のような構成でテーブルを作るとします。

books テーブル
  • id
  • name
stores テーブル
  • id
  • name
books_stores テーブル
  • id
  • book_id
  • store_id
  • count

「booksテーブル」はあくまでも本の情報のみを、
「storesテーブル」はあくまでも店舗情報のみを持ち、
中間テーブル「books_stores」を置く事でbelongsToManyの関係を成立させ
「この本はどの店舗に置いてあるのか」
「この店舗にはどんな本が置いてあるのか」
までを形成する事が出来ます。

 books_stores
___|___
| |
books stores

ちなみにbooks_storesにある「count」というカラムは、何冊在庫があるかというおまけのカラムです。books_storesには在庫があるレコードのみが入ります。

それぞれのモデルのアソシエーションは以下になります。

cakephp/src/Model/Table/BooksTable.php
class BooksTable extends Table
{
public function initialize(array $config)
{
parent::initialize($config);

$this->setTable('books');
$this->setDisplayField('name');
$this->setPrimaryKey('id');

$this->belongsToMany('Stores'); // ← ココ
}
cakephp/src/Model/Table/StoresTable.php
class StoresTable extends Table
{
public function initialize(array $config)
{
parent::initialize($config);

$this->setTable('stores');
$this->setDisplayField('name');
$this->setPrimaryKey('id');

$this->belongsToMany('Books'); // ← ココ
}
cakephp/src/Model/Table/BooksStoresTable.php
class BooksStoresTable extends Table
{
public function initialize(array $config)
{
parent::initialize($config);

$this->setTable('books_stores');
$this->setDisplayField('id');
$this->setPrimaryKey('id');

$this->belongsTo('Books', [
'foreignKey' => 'book_id',
'joinType' => 'INNER'
]);
$this->belongsTo('Stores', [
'foreignKey' => 'store_id',
'joinType' => 'INNER'
]);
}

本の一覧を取得してみます。

cakephp/src/Controller/BooksController.php
use Cake\ORM\TableRegistry;

class BooksController extends AppController
{
public function getData()
{
$books = TableRegistry::get('Books');
$books = $books->find()
->contain(['Stores'])
->all();
}

結果データは以下のようになります。

Array
(
[0] => (
[id] => 1(book_id)
[name] => 'book_A'
[stores] =>
[0] => (
[id] => 61(store_id)
[name] => 'store_61'
[_joinData] => (
[id] => 49(books_store_id)
[book_id] => 1
[store_id] => 61
[count] => 34
)
)
)
,
[1] => (
[id] => 2(book_id)
[name] => 'book_B'
[stores] =>
[0] => (
[id] => 13(store_id)
[name] => 'store_13'
[_joinData] => (
[id] => 81(books_store_id)
[book_id] => 2
[store_id] => 13
[count] => 17
)
)
[1] => (
[id] => 27(store_id)
[name] => 'store_27'
[_joinData] => (
[id] => 23(books_store_id)
[book_id] => 2
[store_id] => 23
[count] => 4
)
)
)
,
.
.
.
)

それぞれの本に対して、在庫を持っている(=books_storesテーブルにレコードが存在している)店舗がリレーションされ結果データが返されます。

続いて、店舗の一覧を取得してみます。

cakephp/src/Controller/StoresController.php
use Cake\ORM\TableRegistry;

class StoresController extends AppController
{
public function getData()
{
$stores = TableRegistry::get('Stores');
$stores = $stores->find()
->contain(['Books'])
->all();
}

結果データは以下のようになります。

Array
(
[0] => (
[id] => 1(store_id)
[name] => 'store_1'
[stores] =>
[0] => (
[id] => 17(book_id)
[name] => 'book_R'
[_joinData] => (
[id] => 49(books_store_id)
[book_id] => 17
[store_id] => 1
[count] => 22
)
)
)
,
[1] => (
[id] => 2(store_id)
[name] => 'store_2'
[stores] =>
[0] => (
[id] => 45(book_id)
[name] => 'book_AK'
[_joinData] => (
[id] => 61(books_store_id)
[book_id] => 45
[store_id] => 2
[count] => 31
)
)
[1] => (
[id] => 27(book_id)
[name] => 'book_V'
[_joinData] => (
[id] => 74(books_store_id)
[book_id] => 27
[store_id] => 2
[count] => 26
)
)
)
,
.
.
.
)

それぞれの店舗に対して、現在在庫のある(=books_storesテーブルにレコードが存在している)本データがリレーションされ結果データが返されます。

このように、中間テーブルを用いる事で互いのマスタテーブルが結合し、かつそれは中間テーブル内で様々な場合によって形成されるレコード(ユニークではない)が作成される。これこそが「多対多」の関係。belongsToManyということになります。

まとめ

アソシエーション。結局はリレーションです。少しこうして分解して眺めるとわりとすんなり入ってくると思います。

また、アソシエーションをきちんと使うと、データの登録や更新の時にも力を発揮してくれて意外と便利ですので是非試してみてください。