PHPでいいね機能を作った、その過程と苦戦… SQL文が難しい

code

PHPで”簡単SNS”を制作中に「いいね機能を付けよう」と思い調べながら進めていましたが、かなりの苦戦… 珍しい機能ではないので色々と見本となるコードは見つかるのですが、期待していた動きになかなか到達できず、Teratailに質問も出すものの回答が付かず、結局いいね機能だけで3日も掛かり、解決しました。その過程を記していきます。

仕様環境はPHP7.4.1 MAMP PHPMyAdmin Chromeブラウザ VSCodeです。

土台と完成形

元となるのはUdemyの“ともすた”さんの「PHP+MySQL Webサーバーサイドプログラミング入門」のSec5「Twitter風ひとこと掲示板」です。このレッスンではアカウント作成・ログイン-ログアウト・メッセージ投稿-返信などのCRUDシステムまでなのですが、そこにプロフィール画面やいいね機能を付け足すことをしていました。

このいいね機能の見本となるのはQiitaのこちらの記事です。この筆者の方もたにぐちさんの「よくわかるPHPの教科書」を元に学習していたそうでデータベースの構造設計も非常によく似たものとなっていたため「このコードですんなり行けそう」と思っていましたが…

作りたかったゴールはTwitterなどでよく見かけるハートマークの横に数字1や2が付いて、ログインしているユーザーがハートマークをクリック“いいね”したらハートマークに色が付く、こんな感じです。

さらにハートマークをクリックで“いいね”が外れハートマークの色も消えます。Qiitaの記事ではハートマークの右の「0」もありますが、今回作りたいのはTwitterの様に「0」は無く、“いいね”が付いた投稿だけその“いいね”数が表示されるというものなので若干Qiitaの記事のものとは異なります。

データベース設計

CREATE TABLE `likes` (
  `id` int(11) NOT NULL,
  `post_id` int(11) NOT NULL,
  `member_id` int(11) NOT NULL,
  `created` datetime NOT NULL,
  `modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

INSERT INTO `likes` (`id`, `post_id`, `member_id`, `created`, `modified`) VALUES
(11, 19, 1, '2021-08-24 23:51:11', '2021-08-24 14:51:11'),
(15, 14, 1, '2021-08-25 09:32:31', '2021-08-25 00:32:31'),


CREATE TABLE `members` (
  `id` int(11) NOT NULL,
  `name` varchar(255) NOT NULL,
  `email` varchar(255) NOT NULL,
  `password` varchar(100) NOT NULL,
  `picture` varchar(255) NOT NULL,
  `introduce` varchar(200) NOT NULL,
  `created` datetime NOT NULL,
  `modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

INSERT INTO `members` (`id`, `name`, `email`, `password`, `picture`, `introduce`, `created`, `modified`) VALUES
(1, 'sakura', 'sakura-example@gmail.com', '5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8', '20210823233156atom.jpg', '初めまして、sakuraです。プログラミング勉強中です。自己紹介文をupdate.phpから編集できるようになりました。日々なんとか成長中', '2021-08-17 13:54:57', '2021-08-23 14:31:56'),
(2, 'wakaba', 'wakaba-example@yahoo.ne.jp', '5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8', '20210821103031animals.jpg', 'どうも、神奈川の大学通ってる学生です。趣味は登山です。', '2021-08-17 21:41:43', '2021-08-21 01:30:31'),


CREATE TABLE `posts` (
  `id` int(11) NOT NULL,
  `message` text NOT NULL,
  `member_id` int(11) NOT NULL,
  `reply_message_id` int(11) NOT NULL DEFAULT '0',
  `created` datetime NOT NULL,
  `modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

INSERT INTO `posts` (`id`, `message`, `member_id`, `reply_message_id`, `created`, `modified`) VALUES
(3, '一覧を作りました。', 1, 0, '2021-08-17 16:30:47', '2021-08-17 07:30:47'),
(4, '初めまして、よろしくお願いします。', 2, 0, '2021-08-17 21:42:13', '2021-08-17 12:42:13'),
(5, '返信機能のため、投稿', 2, 0, '2021-08-17 21:42:31', '2021-08-17 12:42:31'),
(6, 'もう一つ増やします', 2, 0, '2021-08-17 21:42:46', '2021-08-17 12:42:46'),

サンプルデータも少し入れておきましたが、自分なりの表示が欲しい場合はINSERT文の方は無視してください。PHPMyAdminの「SQL」で「SHOW CREATE TABLE テーブル名」と入力でCREATE文も取得できますし、「エクスポート」でファイルをダウンロードしVSCodeで開けば中身を見ることができます。

ソースコード

Qiita記事と大体同じですが、ファイルをindex.phpと関数部分のfunction.phpに分けたのであとでファイルの分け方も書きます。Qiitaのこちらの記事の解説も参考にしてください。

①データベースから投稿取得

// メッセージ別のいいねされた件数をDBから取り出す
$posts = $db->prepare('SELECT m.name, m.picture, p.*, COUNT(l.post_id) AS like_cnt FROM members m, posts p LEFT JOIN likes l ON p.id=l.post_id WHERE m.id=p.member_id GROUP BY l.post_id ORDER BY p.created DESC LIMIT ?, 5');
$posts->bindParam(1, $start, PDO::PARAM_INT);
$posts->execute();

②いいね処理

if (isset($_REQUEST['like'])) {

  //2-1いいねを押したメッセージの投稿者を調べる
  $contributor = $db->prepare('SELECT member_id FROM posts WHERE id=?');
  $contributor->execute(array($_REQUEST['like']));
  $pressed_message = $contributor->fetch();

  //2-2いいねを押した人とメッセージ投稿者が同一人物でないか確認
  if ($_SESSION['id'] != $pressed_message['member_id']) {

    //2-3過去にいいね済みであるか確認
    $pressed = $db->prepare('SELECT COUNT(*) AS cnt FROM likes WHERE post_id=? AND member_id=?');
    $pressed->execute(array(
      $_REQUEST['like'],
      $_SESSION['id']
    ));
    $my_like_cnt = $pressed->fetch();

    //2-4いいねのデータを挿入or削除
    if ($my_like_cnt['cnt'] < 1) {
      $press = $db->prepare('INSERT INTO likes SET post_id=?, member_id=?, created=NOW()');
      $press->execute(array(
        $_REQUEST['like'],
        $_SESSION['id']
      ));
      header("Location: index.php?page={$page}");
      exit();
    } else {
      $cancel = $db->prepare('DELETE FROM likes WHERE post_id=? AND member_id=?');
      $cancel->execute(array(
        $_REQUEST['like'],
        $login_user
      ));
      header("Location: index.php?page={$page}");
      exit();
    }
  }
}

③ハートマークに色付けるmy_like_cnt処理

// ログインしている人がいいねしたメッセージをすべて取得
$like = $db->prepare('SELECT post_id FROM likes WHERE member_id=?');
$like->execute(array($_SESSION['id']));
while ($like_record = $like->fetch()) {
  $my_like[] = $like_record;
}
$my_like_cnt = 0;
  if (!empty($my_like)) {
    foreach ($my_like as $like_post) {
      foreach ($like_post as $like_post_id) {
        if ($like_post_id == $post['id']) {
          $my_like_cnt = 1;
        }
      }
    }
  }

④ハートマークの画面表示部分(9/9追記ミス発覚!! ↓ )

<?php if ($my_like_cnt < 1) : ?>
  <a class="heart" href="index.php?like=<?php echo h($post['id']); ?>&page=<?php echo h($page); ?>">♡</a>
<?php else : ?>
  <a class="heart red" href="index.php?like=<?php echo h($post['id']); ?>&page=<?php echo h($page); ?>">♥</a>
  <span><?php echo h($post['like_cnt']); ?></span>
<?php endif; ?>

コードが長いので流れがイメージしにくいかもしれませんが、④のaタグをクリックするとつまりハートマークをクリックすると$_REQUEST[‘like’]に投稿IDである$post[‘id’]が入り、②のif文が作動します。いきなり$postで「?」かと思いますが、実際の投稿部分は

<?php foreach ($posts as $post) : ?>
│
<? endforeach; ?>

で囲まれていることを想定してください。各投稿を意味する$postということになります。①の$startはページネーション処理を行ったものです。「$start件目から5件表示」になりますが、この「5」も変数にすることも可能で、その際は「5」の部分も「?」にして

$posts->bindParam(2, $show_posts, PDO::PARAM_INT);

のように追加すると「$start件目から$show_posts件表示」とすることができ、「やっぱり1ページに○件表示したいな」を簡単に管理できます。

さらに②では$_SESSION[‘id’]を使って「今、ログインしているユーザーが~~」を意味しますので事前にログイン機能を実装していることを前提としています。2-3で既に“いいね”済か調べて件数を取得し、2-4のSQL文で“いいね”をするINSERTと削除するDELETEです。

コードだけ眺めても「ふ~ん、なるほど…これがこうで…」と理解できましたが、ここから大変でした。

いいね有り無し関わらず全件取得したい

納得したつもりでしたが、このコードでは投稿は1件も取得表示できませんでした…なぜ?エラーなどはなく、投稿入力窓やログアウトボタン、下にはページネーションも表示されるのですが、投稿が1つもない。Udemyで作ってきたSNSシステムに移植という形なので「何かが合わない?」とデータベースの構造から見直し、$_SESSION[]で渡された値も疑いました。Teratailで質問もしましたが、満足いく回答は得られず詰まりました。

ただ自分で全てを説明できるわけではなく特に①のSQL文は長いし、members-posts-likesの3つのテーブルをつなげるので、まず注目したのが「FROM members m, posts p LEFT JOIN likes l」の3つのつなげ方です。「LEFT JOIN 複数」などで検索すると「Oracleの書き方」やLEFT JOINを2つ書く

SELECT * from テーブルA a
left join テーブルB b on (a.CD_A = b.CD_A and a.CD_B = b.CD_B and a.CD_D = b.CD_D)
left join テーブルC c on (b.CD_A = c.CD_A and b.CD_B = c.CD_B and b.CD_E = c.CD_E)

など調べるほどわからなくなる始末…さらにINNER JOINOUTER JOINも聞いたことない。とりあえず何かコードをいじってみることに。

⑤ これが元のSQL 全件取得できる (likesテーブルは無い)

$posts = $db->prepare('SELECT m.name, m.picture, p.* FROM members m, posts p WHERE m.id=p.member_id ORDER BY p.created DESC LIMIT ?,5');

⑥ これで何も1件も取得できない (Qiitaにあったもの①と同じ)

$posts = $db->prepare('SELECT m.name, m.picture, p.*, COUNT(l.post_id) AS like_cnt FROM members m, posts p LEFT JOIN likes l ON p.id=l.post_id WHERE m.id=p.member_id GROUP BY l.post_id ORDER BY p.created DESC LIMIT ?, 5');

⑦これでいいねが付いてる投稿だけ取得 (自分なりに考えて)

$posts = $db->prepare('SELECT m.name, m.picture, p.*, COUNT(l.post_id) AS like_cnt FROM members m, posts p, likes l WHERE m.id=p.member_id AND p.id=l.post_id GROUP BY l.post_id ORDER BY p.created DESC LIMIT ?, 5');

⑤はこのいいね機能を付ける前のSQL文で投稿全件を取得表示できますが、likesテーブルがないので当然“いいね”数は取得できません。がこの状態でもハートマークをクリックで“いいね”の付け外しはできます。ただこの時点ではPHPMyAdminの画面上でデータが入ったり消えたりを確認するしかありません。

⑥がQiitaにあったもの(これで出来ると思っていた…)。⑦は⑥を自分なりに変更しJOINは無し、テーブルの優先度がないので3つのテーブル全てを満たすものだけが取得され、つまり“いいね”していない投稿は無視されます。今回はSNSなので“いいね”有り無し関係なく全投稿を取得表示したいのでこれではダメ。

もう一度UdemyでSQLを復習するとSec3-51でLEFT JOIN-RIGHT JOINの動画があり、試しに⑥のLEFT JOINをRIGHT JOINに変えると…なんと“いいね”した投稿だけ表示されました!!さらにハートマークの横に「1」も付いてます。

JOINはテーブルに優先度をつけLEFT JOINは左側を優先なので「FROM members m, posts p LEFT JOIN likes l」はmemberとpostsにlikesを付ける形なので、つまり「likesがないものも取得」する。これをRIGHT JOINにするということはlikesテーブルを優先し、“いいね”が付いている投稿を取得となったわけです。これで外部結合が出来ていることは確認できました。

ちなみにこの時点では③をhtmlタグの中に入れておらずその上の大枠の<?php~~?>の中に入れていたためハートマークに色は付きませんでした。③は投稿部分の

<?php foreach ($posts as $post) : ?>
│
<? endforeach; ?>

の中に書かなくてはいけません。$post[‘id’]があるので早く気付くべきでした。③のSQL部分は別でも動きますが、$my_like[]が分かれてしまうとイメージしにくいので③はまとめてforeach内に入れました。

正解はやはりSQL文でした

Sec5-50のGROUP BYを見て今回の問題は解決します。「○○ごとに集計する」というGROUP BYを考えて⑥を見ると「GROUP BY l.post_id」となっています。「likesテーブルの投稿idごと?」だとlikesにある投稿しか取得できないのでは?つまりこれが原因でした。Qiitaの記事ではなぜこうなっていたのかわかりませんが、正解は「postsテーブルのidごとに」つまり「GROUP BY p.id」でした。

⑧正解SQL

$posts = $db->prepare('SELECT m.name, m.picture, p.*, COUNT(l.post_id) AS like_cnt FROM members m, posts p LEFT JOIN likes l ON p.id=l.post_id WHERE m.id=p.member_id GROUP BY p.id ORDER BY p.created DESC LIMIT ?, 5');
$posts->bindParam(1, $start, PDO::PARAM_INT);
$posts->execute();

postsテーブルのidごとに集計すれば「membersとpostsテーブルに付属したlikesテーブルに該当するものがあれば取って来てね」が出来るのでした。これで“いいね”有り無しの全投稿が取得表示できました!!

index.phpとfunction.phpに分ける

基本のindex.phpには全ユーザーの投稿が表示されていて、ログイン・投稿を登録・返信・ページネーションなどの処理が既にあるため今回のいいね機能が加わるとコード量が多く読みにくいためいいね機能はfunction.phpに移すことにしました。その方法も紹介しておきます。

ここで問題なのは$startや$postsなどの変数の扱いです。Udemy「PHP+MySQL サーバーサイド」でも言われていましたが、ページを跨ぐと変数がリセットされてしまうため、今回の②いいね処理をfunction.phpに移すと header(“Location: index.php?page={$page}”);の$pageがindex.phpからfunction.phpに渡って来れないことになります。この問題の解決はファイルをrequire(‘’)で読み込むか、$_SESSION[]で値を渡すなどが考えられますので、一応両方ご紹介。

⑨requireで読み込む

今回の必要コードを全てfunction.phpに書いてindex.phpの方で

require(‘function.php’);

をすればfunction.phpの機能がindex.phpで使えることになるので“いいね”の付け外しも問題無く出来る…かと思いますが、⑧の$startはいいね機能以前に作ったページネーション処理にあるので⑧だけはindex.phpに残すと…まだできません。

先ほどの$pageがfunction.phpで受け取れないからです。そこでページネーション処理をfunction.phpに移し、そのまま$pageを読むことも可能ですが

⑩$_SESSIONで読み込む

ページネーション処理の終わりの$start = ($page – 1) * 5;の後で

$_SESSION['page'] = $page;

と書けばsessionで値の受け渡しができるようになるのでfunction.phpの方でもsession_start();として、

header("Location: index.php?page={$page}");
の部分を
header("Location: index.php?page={$_SESSION['page']}");

に変更すれば”いいね“した後のその投稿のあるページにジャンプすることで「”いいね”すると毎回index.phpの1ページ目になってしまう」を防ぐことができました。

今回は$_SESSION[‘id’]もあるのでセッションを使い⑩の方にして、結局②いいね処理だけfunction.phpに移しました。index.phpにforeachの$posts があるので⑧正解SQLもindex.phpに残し$postsの意味を分かりやすくしました。

function.phpでもデータベースとのやり取りがあるので$db = new PDOがあるdbconnect.phpを読み込む

require('dbconnect.php');

も必要になります。値の受け渡しも双方からだいぶ複雑ですが、とりあえずこれで解決です。

9/9追記

④の実際のハートマークと”いいね”件数表示にミスがありましたので訂正します。$my_like_cntは「自分が”いいね”した投稿」があるかないかを調べていて④の書き方では「自分が”いいね”した投稿」だけがハートマーク赤くなって横に”いいね件数”の数字が表示されるだけでした。今回は”いいね”無しの「0」は表示しませんが、Twitterのように他の人が”いいね”した投稿には1や2が表示されていて欲しいので

このように「自分は”いいね”していないけど、誰かが”いいね”した投稿」は白ハートマークに”いいね”件数の表示をしてほしかった、というわけです。

④ハートマークの画面表示部分

<div class="msg_footer_item">
     <?php if ($my_like_cnt < 1) : ?>
         <a class="heart" href="index.php?like=<?php echo h($post['id']); ?>&page=<?php echo h($page); ?>">♡</a>
     <?php else : ?>
         <a class="heart red" href="index.php?like=<?php echo h($post['id']); ?>&page=<?php echo h($page); ?>">♥</a> <!-- ハートマークに色付けるred -->
     <?php endif; ?>

     <?php if (!$post['like_cnt'] == 0) : ?>
        <span>
         <!-- いいねがある時だけ数字表示 SQL文で定義したlike_cnt -->
         <?php echo h($post['like_cnt']); ?>
        </span>
     <?php endif; ?>
</div>

ハートマークを赤くする記述と、数字を表示する部分は別にしてみました。「”いいね”件数は0でなかったら表示してね」とif文を分ける形になりましたが、他にも書き方あったかな?

コメント

タイトルとURLをコピーしました