MySQL,　PHP でアンケート入力フォームを生成する


##はじめに

以下の質問が teratail に。
> [PHP　登録状況で変わる選択肢の状態にあわせてforeach？などで書き出したい](https://teratail.com/questions/62182)
> 
> たびたびお世話になっています。
> アンケートに関するPHPのページを作成しようと思っています。
> 
> １．行いたいことは、アンケートの本文と、選択枝のMySQLへの格納。
> ２．アンケート画面で、データベースから抽出して、選択肢を表示
> ３．チェックボックスやラジオボタンの状態を、DBへ書き込み
> 
> この中の２をどのようにできるかによって１の画面を作りたいと思っています。
> 
> 下記のような配列のテスト画面を作りました。 

> ```php
//test.php
<?php
$survey= array(
    "sur1"=> array("紹介",
                   "看板",
                   "チラシ",
                   "フリーペーパー" => array ("雑誌A",
                                        "雑誌B",
                                         "雑誌C",
                                         "雑誌D"),
                   "ネット" => array ("ホームページ",
                                     "EPARK",
                                     "エキテン",
                                     "LINE",
                                     "Facebook",
                                     "Instagram",
                                     "その他")
                   ),
     "sur2"=> array("A市","B市","D市","E郡","その他のA県内","B県","その他の県"),
     "sur3"=> array("車で5分以内","車で10分以内","車で20分以内","車で30分以内","それ以上")
);
var_dump($survey);  
print '<br><br><br>';
var_dump($survey['sur1']);    
print '<br><br><br>'; 
var_dump($survey['sur1']['フリーペーパー']);
print '<br><br><br>';
//試してみてうまくいかなかったコードです。
//このようにすれば、フリーペーパーの中身は表示されますが、sur1やフリーペーパーをあらかじめ入力するのではなく、変数で入れたいのですが、その変数の取得方法が分かりませんでした。
foreach( $survey['sur1']['フリーペーパー'] as $value ){
  echo $value."<br>"; 
}
?>
```
> 
> 上記のPHPに対してブラウザに表示された内容のコピペです。
> 
> ```php
array(3) {
  ["sur1"]=>
  array(5) {
    [0]=>
    string(6) "紹介"
    [1]=>
    string(6) "看板"
    [2]=>
    string(9) "チラシ"
    ["フリーペーパー"]=>
    array(4) {
      [0]=>
      string(7) "雑誌A"
      [1]=>
      string(7) "雑誌B"
      [2]=>
      string(7) "雑誌C"
      [3]=>
      string(7) "雑誌D"
    }
    ["ネット"]=>
    array(7) {
      [0]=>
      string(18) "ホームページ"
      [1]=>
      string(5) "EPARK"
      [2]=>
      string(12) "エキテン"
      [3]=>
      string(4) "LINE"
      [4]=>
      string(8) "Facebook"
      [5]=>
      string(9) "Instagram"
      [6]=>
      string(9) "その他"
    }
  }
  ["sur2"]=>
  array(7) {
    [0]=>
    string(4) "A市"
    [1]=>
    string(4) "B市"
    [2]=>
    string(4) "D市"
    [3]=>
    string(4) "E郡"
    [4]=>
    string(19) "その他のA県内"
    [5]=>
    string(4) "B県"
    [6]=>
    string(15) "その他の県"
  }
  ["sur3"]=>
  array(5) {
    [0]=>
    string(16) "車で5分以内"
    [1]=>
    string(17) "車で10分以内"
    [2]=>
    string(17) "車で20分以内"
    [3]=>
    string(17) "車で30分以内"
    [4]=>
    string(12) "それ以上"
  }
}
<br><br><br>array(5) {
  [0]=>
  string(6) "紹介"
  [1]=>
  string(6) "看板"
  [2]=>
  string(9) "チラシ"
  ["フリーペーパー"]=>
  array(4) {
    [0]=>
    string(7) "雑誌A"
    [1]=>
    string(7) "雑誌B"
    [2]=>
    string(7) "雑誌C"
    [3]=>
    string(7) "雑誌D"
  }
  ["ネット"]=>
  array(7) {
    [0]=>
    string(18) "ホームページ"
    [1]=>
    string(5) "EPARK"
    [2]=>
    string(12) "エキテン"
    [3]=>
    string(4) "LINE"
    [4]=>
    string(8) "Facebook"
    [5]=>
    string(9) "Instagram"
    [6]=>
    string(9) "その他"
  }
}
<br><br><br>array(4) {
  [0]=>
  string(7) "雑誌A"
  [1]=>
  string(7) "雑誌B"
  [2]=>
  string(7) "雑誌C"
  [3]=>
  string(7) "雑誌D"
}
<br><br><br><br><br><br>雑誌A<br>雑誌B<br>雑誌C<br>雑誌D<br>      
```

> 上記を元に、ブラウザに下記のように出力したいと思っていますが、上記のような配列をもとに、選択肢の数だけ出力するのは可能でしょうか？
> 
> アンケート
ここにアンケート本文
> 
> 〇紹介 〇看板 〇チラシ フリーペーパー【 〇雑誌A 〇雑誌B 〇雑誌C 〇雑誌D 】 ネット 【 〇ホームページ 〇EPARK 〇エキテン 〇LINE 〇Facebook 〇Instagram 〇その他 】
> 
> ここにアンケート本文
〇A市 〇B市 〇D市 〇E郡 〇その他のA県内 〇B県 〇その他の県
> 
> ここにアンケート本文
〇車で5分以内 〇車で10分以内 〇車で20分以内 〇車で30分以内 〇それ以上 

> 上記のように書き出すことが可能であれば、この選択肢を登録する画面や、DBへの書き込み、DBからの書き出しを検討したいと思っています。難解すぎたり、非効率であれば、別の方法を使いたいとも思っていますが、DBへの負荷？などを考えると上記のようにした方がいいような気がしています。
foreachなどをいくつか試しましたが、うまくいきませんでした。
> 
> 実際には、登録状況で変わる選択肢の状態にあわせてforeachで取り出したいのですが、なかなかうまくいきません。上記のように、配列と配列じゃないものが混ざっているのはNGでしょうか？上記のような配列を作るのは、問題があるのか、また書き出し方（チェックボックスやHTMLタグは不要ですが、条件分岐やループの回し方を知りたいです）等、お知恵をお借りできればと思っています。
> 
> お手数おかけしますが、よろしくお願いします。 

##要件

上記の案件について、要件をまとめると、
問題となっているのは、画面の出力部分なので、DBテーブル設計が肝となります。

###必要なテーブル

0. 設問テーブル
0. 選択肢テーブル

上記の2点となる。
選択肢テーブルはツリー構造となるため、`parentId` カラムを持って、上下関係を持つ必要があります。

解説は後日書き足すとして、テーブル定義、ファイルを上げておきます。

##テーブル定義

```sql
-- 設問テーブル
CREATE TABLE `question` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
  `sentence` varchar(256) NOT NULL DEFAULT '' COMMENT '質問文',
  `orderNumber` int(11) DEFAULT NULL COMMENT '表示順',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='設問';
```

```sql
-- 選択肢テーブル
CREATE TABLE `choice` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
  `parentId` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '親ID',
  `questionId` int(10) unsigned DEFAULT NULL COMMENT '設問ID',
  `sentence` varchar(1000) DEFAULT NULL COMMENT '選択肢文言',
  `orderNumber` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '表示順',
  PRIMARY KEY (`id`),
  KEY `parentId` (`parentId`),
  KEY `questionId` (`questionId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='選択肢';
```

##サンプルデータ

```sql
INSERT INTO `question` (`id`, `sentence`, `orderNumber`)
VALUES
	(1,'職場までの通勤時間は？',2),
	(2,'この案件を何で知りましたか？',1);
```

```sql
INSERT INTO `choice` (`id`, `parentId`, `questionId`, `sentence`, `orderNumber`)
VALUES
	(1,0,1,'車で5分以内',1),
	(2,0,1,'車で10分以内',2),
	(3,0,1,'車で20分以内',3),
	(4,0,1,'車で30分以内',4),
	(5,0,1,'それ以上',5),
	(6,0,2,'紹介',1),
	(7,0,2,'看板',2),
	(8,0,2,'チラシ',3),
	(9,0,2,'フリーペーパー',4),
	(10,9,2,'雑誌A',5),
	(11,9,2,'雑誌B',6),
	(12,9,2,'雑誌C',7),
	(13,9,2,'雑誌D',8),
	(14,0,2,'ネット',9),
	(15,14,2,'ホームページ',10),
	(16,14,2,'EPARK',11),
	(17,14,2,'エキテン',12),
	(18,14,2,'LINE',13),
	(19,14,2,'Facebook',14),
	(20,14,2,'Instagram',15),
	(21,14,2,'その他',16);
```

###PHPディレクトリ構造

ディレクトリ構造は以下の図のようになっています。

![スクリーンショット 0029-01-16 20.11.06.png](https://qiita-image-store.s3.amazonaws.com/0/46841/d02128db-584d-b236-6672-fbc88de43a65.png "スクリーンショット 0029-01-16 20.11.06.png")

##Model

```php:QuestionModel.php
<?php

/**
 * QuestionModel.php
 */
class QuestionModel
{

	private $id = null;
	private $sentence = null;
	private $orderNumber = null;

	public function getId()
	{
		return $this->id;
	}

	public function getSentence()
	{
		return $this->sentence;
	}

	public function getOrderNumber()
	{
		return $this->orderNumber;
	}

	public function setParams(array $row)
	{
		$this->setId($row['id'])
			->setSentence($row['sentence'])
			->setOrderNumber($row['orderNumber']);
		return $this;
	}

	public function setId($id)
	{
		$this->id = $id;
		return $this;
	}

	public function setSentence($sentence)
	{
		$this->sentence = $sentence;
		return $this;
	}

	public function setOrderNumber($orderNumber)
	{
		$this->orderNumber = $orderNumber;
		return $this;
	}

}
```

```php:ChoiceModel.php
<?php

/**
 * ChoiceModel.php
 */
class ChoiceModel
{

	private $id = null;
	private $parentId = null;
	private $questionId = null;
	private $sentence = null;
	private $orderNumber = null;

	public function getId()
	{
		return $this->id;
	}

	public function getParentId()
	{
		return $this->parentId;
	}

	public function getQuestionId()
	{
		return $this->questionId;
	}

	public function getSentense()
	{
		return $this->sentence;
	}

	public function getOrderNumber()
	{
		return $this->orderNumber;
	}

	public function setParams(array $row)
	{
		$this->setId($row['id'])
			->setParentId($row['parentId'])
			->setQuestionId($row['questionId'])
			->setSentense($row['sentence'])
			->setOrderNumber($row['orderNumber']);
		return $this;
	}

	public function setId($id)
	{
		$this->id = $id;
		return $this;
	}

	public function setParentId($parentId)
	{
		$this->parentId = $parentId;
		return $this;
	}

	public function setQuestionId($questionId)
	{
		$this->questionId = $questionId;
		return $this;
	}

	public function setSentense($sentence)
	{
		$this->sentence = $sentence;
		return $this;
	}

	public function setOrderNumber($orderNumber)
	{
		$this->orderNumber = $orderNumber;
		return $this;
	}

}
```

##Controller

```php:QuestionController.php
<?php

/**
 * QuestionController.php
 */
class QuestionController
{

	/**
	 * 設問を全て取得する
	 */
	public static function all()
	{
		$sql = 'SELECT ';
		$sql .= 'id, sentence, orderNumber ';
		$sql .= 'FROM question ';
		$sql .= 'ORDER BY orderNumber ASC';
		$params = [];
		$rows = DB::select($sql, $params);

		$arrObjQuestionModels = [];
		foreach ($rows as $row) {
			$objQuestionModel = new QuestionModel();
			$objQuestionModel->setParams($row);
			$arrObjQuestionModels[] = $objQuestionModel;
		}
		return $arrObjQuestionModels;
	}

}
```

```php:ChoiceController.php
<?php

/**
 * ChoiceController.php
 */
class ChoiceController
{

	private static $choiceGroup = [];

	/**
	 * 子孫となる選択肢を取得
	 */
	public static function getChildren($questionId, $id)
	{
		if (isset(self::$choiceGroup[$id])) {
			return self::$choiceGroup[$id];
		}
		$sql = 'SELECT ';
		$sql .= 'id';
		$sql .= ', parentId';
		$sql .= ', questionId';
		$sql .= ', sentence';
		$sql .= ', orderNumber ';
		$sql .= 'FROM choice ';
		$sql .= 'WHERE questionId = :questionId ';
		$sql .= 'AND parentId = :parentId ';
		$sql .= 'ORDER BY orderNumber ASC';
		$params = [];
		$params[':questionId'] = $questionId;
		$params[':parentId'] = $id;
		$rows = DB::select($sql, $params);
		foreach ($rows as $row) {
			$objCoiceModel = new ChoiceModel();
			$objCoiceModel->setParams($row);
			self::$choiceGroup[$row['parentId']][] = $objCoiceModel;
		}
		return (isset(self::$choiceGroup[$id])) ? self::$choiceGroup[$id] : [];
	}

	/**
	 * 設問ごとの選択肢を取得する
	 * @param type $questionId
	 */
	public static function getByQuestionId($questionId)
	{
		$sql = 'SELECT ';
		$sql .= 'id';
		$sql .= ', parentId';
		$sql .= ', questionId';
		$sql .= ', sentence';
		$sql .= ', orderNumber ';
		$sql .= 'FROM choice ';
		$sql .= 'WHERE questionId = :questionId ';
		$sql .= 'AND parentId = :parentId ';
		$sql .= 'ORDER BY orderNumber ASC';
		$params = [];
		$params[':questionId'] = $questionId;
		$params[':parentId'] = '0';
		$rows = DB::select($sql, $params);
		$arrObjCoiceModels = [];
		foreach ($rows as $row) {
			$objCoiceModel = new ChoiceModel();
			$objCoiceModel->setParams($row);
			$arrObjCoiceModels[] = $objCoiceModel;
		}
		return $arrObjCoiceModels;
	}

}
```

##lib

```php:DB.php
<?php

/**
 * DB.php
 */
class DB
{

	const DSN = 'mysql:host=localhost;dbname=questionnaire;charset=utf8;';
	const DB_USER = 'root';
	const DB_PASSWORD = 'password';

	private static function _connect()
	{
		$options = [
			PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
			, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC
		];
		$pdo = new PDO(self::DSN, self::DB_USER, self::DB_PASSWORD, $options);
		return $pdo;
	}

	/**
	 *
	 * @param string $sql
	 * @param array $params
	 * @return array
	 */
	public static function select($sql, $params)
	{
		$pdo = self::_connect();
		$stmt = $pdo->prepare($sql);
		$stmt->execute($params);
		return $stmt->fetchAll();
	}

}
```

###その他

```php:common.php
<?php

/**
 * common.php
 */
function h($string)
{
	return htmlspecialchars($string, ENT_QUOTES, 'utf-8');
}

/**
 * autoload
 * @param type $class
 */
function autoload($class)
{
	if (preg_match('/\w+Controller/', $class)) {
		$file = sprintf('controller/%s.php', $class);
	} else if (preg_match('/\w+Model/', $class)) {
		$file = sprintf('model/%s.php', $class);
	} else if (preg_match('/\w+/', $class)) {
		$file = sprintf('lib/%s.php', $class);
	}
	if (file_exists($file)) {
		require $file;
	}
}

spl_autoload_register('autoload');
```

```html:index.php
<?php
ini_set('display_errors', true);
error_reporting(E_ALL);

require 'common.php';
$questions = QuestionController::all();
?>
<!DOCTYPE HTML>
<html lang="ja">
	<head>
		<meta charset="UTF-8">
		<title>アンケート</title>
		<style type="text/css">
			.sentence:before {
				content: 'Q.';
				margin-right: 0.5em;
			}
			.question ul {
				list-style: none;
			}
		</style>
	</head>
	<body>
		<div id="questionnaire">
			<form action="" method="post">
				<?php foreach ($questions as $question) : ?>
					<div class="question">
						<p class="sentence"><?= h($question->getSentence()); ?></p>
						<ul class="choice">
							<?php foreach (ChoiceController::getByQuestionId($question->getId()) as $choice) : ?>

								<?php if (0 === count(ChoiceController::getChildren($question->getId(), $choice->getId()))) : ?>
									<li>
										<label>
											<input type="radio" name="question[<?= h($question->getId()); ?>]" value="<?= h($choice->getId()); ?>" />
											<?= h($choice->getSentense()); ?>
										</label>
									</li>
								<?php else: ?>
									<li>
										<?= h($choice->getSentense()); ?>
									</li>
									<ul>
										<?php foreach (ChoiceController::getChildren($question->getId(), $choice->getId()) as $child) : ?>
											<li>
												<label>
													<input type="radio" name="question[<?= h($question->getId()); ?>]" value="<?= h($child->getId()); ?>" />
													<?= h($child->getSentense()); ?>
												</label>
											</li>
										<?php endforeach; ?>
									</ul>
								<?php endif; ?>
							<?php endforeach; ?>
						</ul>
					</div>
				<?php endforeach; ?>
			</form>
		</div>
	</body>
</html>
```

##スクリーンショット

![localhost_2017-01-16_20-23-45.png](https://qiita-image-store.s3.amazonaws.com/0/46841/f2d64f31-ff3c-1427-cf2f-eea82e4a7ff2.png "localhost_2017-01-16_20-23-45.png")