1. Home
  2. PHP
  3. CakePHP
  4. CakePHP3のアソシエーションでリレーション(JOIN)を行いデータを取得する

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

  • 公開日
  • 更新日
  • カテゴリ:CakePHP
  • タグ:PHP,CakePHP,Model,Table,Associations
CakePHP3のアソシエーションでリレーション(JOIN)を行いデータを取得する

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

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

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

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

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

Contents

  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   3
     システム管理者 管理者 作業者
-----------------------------------------
●ユーザ情報テーブル
ユーザB[ role : 2 ]の場合

●権限マスタテーブル
              ↓
    値   1     2   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 ということになります。

まとめ

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

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

Author

rito

  • Backend Engineer
  • Tokyo, Japan
  • PHP 5 技術者認定上級試験 認定者
  • 統計検定 3 級