【備忘録】初めてのCakePHP④ - アソシエイト編


# 一対多

## 記事(Articles)に対するコメント(Comments)

### モデルによる関連付け

```php:src/Model/Table/ArticlesModel.php
public function initialize(array $config)
{
  parent::initialize($config);
  // 中略
  $this->hasMany('Comments', [
    'foreignKey' => 'article_id',
  ]);
}
```

```php:src/Model/Table/CommentsModel.php
public function initialize(array $config)
{
  parent::initialize($config);
  // 中略
  $this->hasMany('Articles', [
    'foreignKey' => 'article_id',
  ]);
}
```


### コメントの保存

記事の詳細画面(Template/Articles/show.ctp)にコメント一覧、コメント投稿フォームをつける

```php:src/Controller/ArticlesController.php
  /**
   * Show method
   *
   * @param string|null $id Article id.
   * @return \Cake\Http\Response|null
   * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found.
   */
  public function show($id = null)
  {
//    $dsn = 'mysql://admin:admin@db_master/cake_blog';
//    ConnectionManager::config('default', ['url' => $dsn]);
    $connection = ConnectionManager::get('default');
    $article = $this->Articles->get($id, [
      'contain' => ['Comments']
    ]);
    $comment = $this->Articles->Comments->newEntity();
    $this->set(compact(['connection', 'article', 'comment']));
  }

```

```php:src/Controller/CommentsController.php
  /**
   * Add method
   *
   * @return \Cake\Http\Response|null Redirects on successful add, renders view otherwise.
   */
  public function add()
  {
    $params = $this->request->getData();
    $comment = $this->Comments->newEntity();
    if ($this->request->is('post')) {
      $comment = $this->Comments->patchEntity($comment, $this->request->getData());
      if ($this->Comments->save($comment)) {
        $this->Flash->success(__('The comment has been saved.'));
      } else {
        $this->Flash->error(__('The comment could not be saved. Please, try again.'));
      }
      // 保存に成功しても失敗してもリダイレクト先はArticlesControllerのshowメソッド
      return $this->redirect(['controller' => 'articles', 'action' => 'show', $params['article_id']]);
    }
    $articles = $this->Comments->Articles->find('list', ['limit' => 200]);
    $this->set(compact('comment', 'articles'));
  }

  /**
   * Delete method
   *
   * @param string|null $id Comment id.
   * @return \Cake\Http\Response|null Redirects to index.
   * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found.
   */
  public function delete($id = null)
  {
    $this->request->allowMethod(['post', 'delete']);
    $params = $this->request->getData();
    $comment = $this->Comments->get($id);
    if ($this->Comments->delete($comment)) {
      $this->Flash->success(__('The comment has been deleted.'));
    } else {
      $this->Flash->error(__('The comment could not be deleted. Please, try again.'));
    }
    return $this->redirect(['controller' => 'articles', 'action' => 'show', $params['article_id']]);
  }
```

#### ビュー（記事詳細画面）

```php:Template/Articles/show.ctp
<h1><?= $article->title; ?></h1>
<p><?= nl2br($article->body); ?></p>

<h2>Comments</h2>
<!--コメント一覧-->
<?php if( count($article->comments) > 0 ): ?>
<ul>
  <?php foreach($article->comments as $comment): ?>
  <li>
    <span><?= $comment->body; ?></span>
    <?php
    /**
     * 第1引数: リンクのテキスト
     * 第2引数: $url
     * 第3引数: $options(今回は'confirm'と'data'を配列形式で指定)
     */
    echo $this->Form->postLink(
      '削除',
      ['controller' => 'comments', 'action' => 'delete', $comment->id],
      [
        'confirm' => '一度削除すると元に戻せません。削除してよろしいですか?',
        'data' => [
          'article_id' => $article->id,
        ],
      ]
    );
    ?>
  </li>
  <?php endforeach; ?>
</ul>
<?php else: ?>
<p>コメントはありません。</p>
<?php endif; ?>

<!--コメントフォーム-->
<?php
echo $this->Form->create('Comment', ['url' => ['controller' => 'comments', 'action' => 'add']]);
echo $this->Form->input('article_id', ['type' => 'hidden', 'value' => $article->id]);
echo $this->Form->input('body', ['rows' => '1', 'placeholder' => 'コメント']);
echo $this->Form->button(__('Save Comment'));
echo $this->Form->end();
?>

<a onclick="history.back()">BACK</a>
```

[GitHub](https://github.com/rk1quol/CakeBlogTutorial)
[Formヘルパーの参考](https://book.cakephp.org/3/ja/views/helpers/form.html)



# 多対多

## 記事(Articles)に対する複数タグ(Tags)

#### `tagsテーブル`と`articles_tagsテーブル（中間テーブル）`を作成する

```bash:/var/www/cakeapp
$ bin/cake bake migration CreateTags \
name:string[30]:unique:BY_NAME created modified

// 中間テーブルのテーブル名はABC順で定義する
$ bin/cake bake migration CreateArticlesTags \
article_id:integer[11] tag_id:integer[11] created modified
$ bin/cake migrations migrate

// モデルの作成
$ bin/cake bake model Tags
$ bin/cake bake model ArticlesTags
```

#### モデル定義
```php:src/Models/Table/ArticlesTable.php
public function initialize(array $config)
{
  $this->hasMany('Comments', [
    'foreignKey' => 'article_id',
  ]);
  // ↓追加
  $this->belongsToMany('Tags', [
    'foreignKey' => 'article_id',
    'targetForeignKey' => 'tag_id',
    'joinTable' => 'articles_tags',
  ])->setProperty('tags');
}

/**
 * @param Query $query
 * @param array $options
 * @return Query
 */
public function findTag(Query $query, array $options)
{
  $tagId = $options['tagId'];
  return $this->find()->matching('Tags', function($q) use ($tagId) {
    return $q->where([ 'Tags.id' => $tagId ]);
  });
}
```

```php:src/Models/Table/TagsTable.php
public function initialize(array $config)
{
  // 中略
  $this->belongsToMany('Articles', [
    'foreignKey' => 'tag_id',
    'targetForeignKey' => 'article_id',
    'joinTable' => 'articles_tags',
  ])->setProperty('articles');
}
```


#### 記事一覧の修正

```php:src/Controller/ArticlesController.php

  /**
   * Index method
   *
   * @return \Cake\Http\Response|null
   */
  public function index()
  {
    if($tagId = (int)$this->request->getQuery('tag')){
      // クエリパラメータ"tag"がある場合
      $query = $this->Articles->find('tag', [
        'tagId' => $tagId
      ]);
      $articles = $this->paginate($query);
      $tag = $this->loadModel('Tags')->get($tagId);
      $this->set(compact('tag'));
    } else {
      // クエリパラメータ"tag"がない場合
      $articles = $this->paginate($this->Articles);
    }
    $this->set(compact('articles'));
  }
```

```php:src/Template/Articles/index.ctp
<h1>Blog articles <button><?= $this->Html->link('Add', ['action' => 'add']) ?></button></h1>
<table>
  <tr>
    <th class="short">Id</th>
    <th>Title</th>
    <th>Tags</th>
  </tr>
<?php foreach ($articles as $article): ?>
  <tr>
    <td><?= $article->id ?></td>
    <td>
      <?= $this->Html->link($article->title, ['action' => 'show', $article->id]) ?>
    </td>
  <?php if(count($article->tags) > 0): ?>
    <td>
    <?php foreach($article->tags as $tag): ?>
      <span><?= $tag->name; ?></span>
    <?php endforeach; ?>
    </td>
  <?php else: ?>
    <td>
    </td>
  <?php endif; ?>
```

#### 記事の保存

```php:src/Models/Entity/Article.php
  protected $_accessible = [
    'title' => true,
    'body' => true,
    'created' => true,
    'modified' => true,
    // ↓追加(trueにしないと保存できない)
    'tags' => true,
  ];
```

記事保存の際、複数のタグを関連付けて保存できるようにする。
作りたいリクエストデータは以下の通り。

```php:
$data = [
  'title' => 'タイトル',
  'body' => '記事本文。',
  'tags' => [
    // tagsテーブルに既存のタグが送信された場合
    ['id' => '既存のタグID1'],
    ['id' => '既存のタグID2'],
    // tagsテーブルにないタグが送信された場合
    ['name' => '新しいタグ1'],
    ['name' => '新しいタグ2'],
  ],
];
```

tagsの部分は`makeTagArr`メソッドを作り、`add`メソッド、`edit`メソッドでそれぞれ呼び出す。

```php:src/Controller/ArticlesController.php

  /**
   * Add method
   *
   * @return \Cake\Http\Response|null Redirects on successful add, renders view otherwise.
   */
  public function add()
  {
    $article = $this->Articles->newEntity();
    if ($this->request->is('post')) {
      $data = $this->request->getData();
      // 作りたいリクエストデータに合う配列を作る
      $data['tags'] = $this->makeTagArr($data['tags']);
      $article = $this->Articles->patchEntity($article, $data, [
        'associated' => ['Tags']
      ]);
      // ↑の1行は、以下のようにも書ける
      //$newData = ['user_id' => $this->Auth->user('id')];
      //$article = $this->Articles->patchEntity($article, $newData);
      if ($this->Articles->save($article)) {
        $this->Flash->success(__('記事を投稿しました。'));
        return $this->redirect(['action' => 'index']);
      } else {
        $this->Flash->error(__('投稿に失敗しました'));
      }
    }
    $article = $this->Articles->newEntity();
    $this->set(compact(['article']));
  }

  /**
   * Edit method
   *
   * @param string|null $id Article id.
   * @return \Cake\Http\Response|null Redirects on successful edit, renders view otherwise.
   * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found.
   */
  public function edit($id = null)
  {
    $article = $this->Articles->get($id, [
      'contain' => ['Tags'],
    ]);
    if ($this->request->is(['patch', 'post', 'put'])) {
      $data = $this->request->getData();
      $data['tags'] = $this->makeTagArr($data['tags']);

      $article = $this->Articles->patchEntity($article, $data, [
        'associated' => ['Tags']
      ]);
      if ($this->Articles->save($article)) {
        $this->Flash->success(__('記事を更新しました'));

        return $this->redirect(['action' => 'index']);
      }
      $this->Flash->error(__('The article could not be saved. Please, try again.'));
    }
    $c = new Collection($article->tags);
    $tagsArr = $c->extract(function($tag){
      return "#{$tag->name}";
    })->toList();
    $tags = implode(' ', $tagsArr);
    $this->set(compact(['article', 'tags']));
  }

  protected function makeTagArr($tagsStr)
  {
    $tags = explode('#', preg_replace('/( |　)/', '', $tagsStr ));
    array_splice($tags, 0, 1);
    $tagsTable = TableRegistry::getTableLocator()->get('Tags');
    $arr = [];
    foreach($tags as $tag) {
      if (!empty($row = $tagsTable->findByName($tag)->first() ?? '')) {
        array_push($arr, ['id' => $row->id]);
      } else {
        array_push($arr, ['name' => $tag]);
      }
    }
    return $arr;
  }
```

```php:src/Template/Articles/add.ctp
<h1>Add Article</h1>
<?php
echo $this->Form->create($article);
echo $this->Form->control('title');
echo $this->Form->control('body', ['rows' => '3']);
echo $this->Form->control('tags', [
  'placeholder' => '例） #PHP #HTML #CSS',
  'type' => 'text' 
]);
echo $this->Form->button(__('Save Article'));
echo $this->Form->end();
?>
```

```php:src/Template/Articles/edit.ctp
<h1><?= $article->title ?? ''; ?></h1>
<?php
echo $this->Form->create($article);
echo $this->Form->control('title');
echo $this->Form->control('body', ['rows' => '3']);
echo $this->Form->control('tags', [
  'placeholder' => '例） #PHP #HTML #CSS',
  'type' => 'text' 
]);
echo $this->Form->button(__('Edit Article'));
echo $this->Form->end();
?>
```

## フォロー機能

#### `follows`テーブル作成
```bash:/var/www/cakeapp
$ bin/cake bike migration CreateFollows follow_id:integer[11] follower_id:integer[11] created modified
$ bin/cake migrations migrate
$ bin/cake bake model Follows
```

#### Followモデル

```php:src/Model/Table/FollowsTable.php
class FollowsTable extends Table
{
  /**
   * Initialize method
   *
   * @param array $config The configuration for the Table.
   * @return void
   */
  public function initialize(array $config)
  {
    parent::initialize($config);

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

    $this->addBehavior('Timestamp');
    // ↓アソシエーション
    $this->belongsTo('Follows', [
      'foreignKey' => 'follow_id',
      'joinTable' => 'users',
      'joinType' => 'INNER',
    ])->setProperty('follow');

    $this->belongsTo('Followers', [
      'className' => 'Users',
      'foreignKey' => 'follower_id',
      'joinTable' => 'users',
      'joinType' => 'INNER',
    ])->setProperty('follower');
  }

  public function buildRules(RulesChecker $rules)
  {
    $rules->add($rules->existsIn(['follow_id'], 'Users'));
    $rules->add($rules->existsIn(['follower_id'], 'Users'));

    return $rules;
  }
```

```php:src/Model/Entity/Follow.php
  protected $_accessible = [
    'follow_id' => true,
    'follower_id' => true,
    'created' => true,
    'modified' => true,
    'follow' => true,
    'follower' => true,
  ];
```

#### Userモデル

最初はアソシエーションの定義に`Follows`という名前を使っていたが、followsテーブルを作ってしまったせいでコンフリクトが起きたものと思われる。代替案として`FollowMembers`にする。

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

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

    $this->addBehavior('Timestamp');
    // ↓アソシエーション追加
    $this->belongsToMany('FollowMembers', [
      'className' => 'Users',
      'joinTable' => 'follows',
      'foreignKey' => 'follow_id',
      'targetForeignKey' => 'follower_id',
    ])->setProperty('follows');

    $this->belongsToMany('FollowerMembers', [
      'className' => 'Users',
      'joinTable' => 'follows',
      'foreignKey' => 'follower_id',
      'targetForeignKey' => 'follow_id',
    ])->setProperty('followers');
  }
```

```php:src/Table/Entity/User.php
  protected $_accessible = [
    'id' => false,
    'follows' => true,
    'followers' => true,
    '*' => true,
  ];
```

#### コントローラ
```php:src/Controller/UsersController.php
  public function show($id = null)
  {
    $user = $this->Users->get($id, [
      'contain' => ['FollowMembers', 'FollowerMembers'],
    ]);
    $c = new Collection($user->followers);
    $followerIds = $c->extract('id')->toList();
    $this->set(compact(['user', 'followerIds']));
  }

　// フォロー
  public function follow($id = null)
  {
    $user = $this->Users->get($id, [
      'contain' => ['FollowMembers', 'FollowerMembers'],
    ]);

    if ($this->request->is(['patch', 'post', 'put'])) {
      // followerたち（idの配列）にログイン中の自分のIDをつなげる
      $data = $this->request->getData();
      $data['followers']['_ids'] = empty($data['followers']['_ids']) ? [$this->Auth->user('id')] : array_merge($data['followers']['_ids'], [$this->Auth->user('id')]);

      $user = $this->Users->patchEntity($user, $data, [
        'associated' => ['FollowerMembers'],
      ]);
      if ($this->Users->save($user)) {
        $this->Flash->success(__('フォローしました。'));
      } else {
        $this->Flash->error(__('フォローに失敗しました. 恐れ入りますが、再度お試しください'));
      }
    }

    return $this->redirect(['action' => 'show', $user->id]);
  }

  // フォロー解除
  public function unfollow($id = null)
  {
    $user = $this->Users->get($id, [
      'contain' => ['FollowMembers', 'FollowerMembers'],
    ]);
    if ($this->request->is(['patch', 'post', 'put'])) {

      // followerたち（idの配列）の中からログイン中の自分のIDを排除する
      $data = $this->request->getData();
      $index = array_search($this->Auth->user('id'), $data['followers']['_ids']);
      array_splice($data['followers']['_ids'], $index, 1);

      $user = $this->Users->patchEntity($user, $data, [
        'associated' => ['FollowerMembers'],
      ]);
      if ($this->Users->save($user)) {
        $this->Flash->success(__('フォローを解除しました'));
      } else {
        $this->Flash->error(__('フォローの解除に失敗しました. 恐れ入りますが、再度お試しください'));
      }
      return $this->redirect(['action' => 'show', $user->id]);
    }
  }
```

#### ビュー
```php:src/Template/Users/show.ctp
<h1>
  <span><?= $user->username; ?></span>
  <!-- ↓フォロー（フォロー解除）リンク -->
  <?php
    $isFollowing = in_array($this->Auth->user('id'), $followerIds);
    $action = $isFollowing ? 'unfollow' : 'follow';
    $string = $isFollowing ? 'フォロー解除' : 'フォロー';
    // 現在表示中の$userのIDをパラメータにする
    echo $this->Form->postLink($string,
      ['action' => $action, $user->id],
      [
        // $userのfollowerたち（idの配列）を、リクエストのボディデータとして送信する
        'data' => [
          'followers._ids' => $followerIds
        ]
      ]
    );
  ?>
</h1>

<h2 class="flex">
  <span>フォロー:</span>
  <span><?= count($user->follows); ?></span>
</h2>
<!--フォロー一覧-->
<?php if( count($user->follows) > 0 ): ?>
<ul>
  <?php foreach($user->follows as $follow): ?>
  <li>
    <?= $this->Html->link($follow->username, [ 'action' => 'show', $follow->id ]); ?>
  </li>
  <?php endforeach; ?>
</ul>
<?php else: ?>
<p>フォロー中のユーザーはいません。</p>
<?php endif; ?>

<h2 class="flex">
  <span>フォロワー:</span>
  <span><?= count($user->followers); ?></span>
</h2>
<!--フォロワー一覧-->
<?php if( count($user->followers) > 0 ): ?>
  <ul>
    <?php foreach($user->followers as $follower): ?>
      <li>
        <?= $this->Html->link($follower->username, [ 'action' => 'show', $follower->id ]); ?>
      </li>
    <?php endforeach; ?>
  </ul>
<?php else: ?>
  <p>フォロワーはいません。</p>
<?php endif; ?>

<a onclick="history.back()">BACK</a>

```