PHP --- フレームワークを利用しないでToDoアプリ作成(3)

前回は、ルーティングの機能まで実装しました。
PHP --- フレームワークを利用しないでToDoアプリ作成(2) - 何でもプログラミング

今回はデータベースと連携して、実際のToDoアプリを構築してみたいと思います。

データベースはSQLiteを利用しており、CSRF対策はまだ入れていません。

今回の作成物

下図のように、ToDoの一覧ページと、追加ページからなります。

簡単のため、編集の機能は搭載してありません。

f:id:any-programming:20180626170455p:plain f:id:any-programming:20180626170559p:plain

View部分は、layout.php、todoList.php、todoAdd.phpの3ファイルからなります。

データベースは、database.sqliteという名前のファイルで、下記のtodoテーブルが定義してあります。

id INTEGER PRIMARY KEY AUTOINCREMENT
date TEXT
text TEXT


layout.php

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title><?php safeEcho($title) ?></title>
</head>
<body>
<header>
    <p>ToDo</p>
</header>
<main>
    <?php echo $body ?>
</main>
</body>
</html>

todoList.php

<?php ob_start(); ob_implicit_flush(0); ?>

<a href="/add">追加</a>

<table>
<?php foreach ($todos as $todo) { ?>
    <tr>
        <td>
            <?php safeEcho($todo->date->format('Y/m/d')) ?>
        </td>
        <td>
            <?php safeEcho($todo->text) ?>
        </td>
        <td>
            <form action="/delete" method="post">
                <input type="hidden" name="id" value="<?php safeEcho($todo->id) ?>">
                <button type="submit">削除</button>
            </form>
        </td>
    </tr>
<?php } ?>
</table>

<?php
$title = 'ToDo一覧';
$body  = ob_get_clean();
require 'layout.php';
?>

todoAdd.php

<?php ob_start(); ob_implicit_flush(0); ?>

<?php if (empty($errors) === false) { ?>
<ul>
<?php foreach ($errors as $error) { ?>
    <li>
        <?php safeEcho($error) ?>
    </li>
<?php } ?>
</ul>
<?php } ?>

<form action="/add" method="post">
    <ul>
        <li>
            <label>
                日付
                <input type="text" name="date" value="<?php safeEcho($date) ?>">
            </label>
        </li>
        <li>
            <label>
                テキスト
                <input type="text" name="text" value="<?php safeEcho($text) ?>">
            </label>
        </li>
        <li>
            <button type="submit">送信</button>
        </li>
    </ul>
</form>

<?php
$title = 'ToDo追加';
$body  = ob_get_clean();
require 'layout.php';
?>

index.php

<?php
function connectDB() : PDO {
    return connectSQLite('database.sqlite');
}

class ToDo {
    const DATE_FORMAT = 'Y/m/d';
    public $id;
    public $date;
    public $text;
    public static function insert(PDO $db, DateTime $date, string $text) : void {
        $db->prepare('INSERT INTO todo(date, text) VALUES (?, ?)')
            ->execute([ $date->format(self::DATE_FORMAT), $text ]);
    }
    public static function delete(PDO $db, int $id) : void {
        $db->prepare('DELETE FROM todo WHERE id = ?')
            ->execute([ $id ]);
    }
    public static function exists(PDO $db, int $id) : bool {
        $statement = $db->prepare('SELECT * FROM todo WHERE id = ?');
        $statement->execute([ $id ]);
        return empty($statement->fetchAll()) === false;
    }
    public static function fetchAll(PDO $db) : array {
        $statement = $db->prepare('SELECT * FROM todo ORDER BY date DESC');
        $statement->execute();
        return array_map(function($x) {
            $todo = new ToDo();
            $todo->id   = $x['id'];
            $todo->date = DateTime::createFromFormat(self::DATE_FORMAT, $x['date']);
            $todo->text = $x['text'];
            return $todo;
        }, $statement->fetchAll());
    }
}

function todoList() : Response {
    $db = connectDB();
    $todos = ToDo::fetchAll($db);
    $content = render('todoList.php', compact('todos'));
    return Response::ok($content);
}

function todoAdd() : Response {
    $errors = [];
    $date = (new DateTime())->format('Y/m/d');
    $text = '';
    $content = render('todoAdd.php', compact('errors', 'date', 'text'));
    return Response::ok($content);
}

function post_todoAdd() : Response {
    $date = assertPOST('date');
    $text = assertPOST('text');

    $validate = new Validate();
    $validate->isTrue(empty($text) === false,  'テキストが空です。');
    $validate->isTrue(mb_strlen($text) <= 100, 'テキストは100文字までです。');

    $dateObj = DateTime::createFromFormat('Y/m/d', $date);
    $validate->isTrue($dateObj !== false, '日付が****/**/**の形式ではありません。');

    $errors = $validate->messages;
    if (empty($errors)) {
        $db = connectDB();
        ToDo::insert($db, $dateObj, $text);
        return Response::redirect('/');
    } else {
        $content = render('todoAdd.php', compact('errors', 'date', 'text'));
        return Response::badRequest($content);
    }
}

function post_todoDelete() : Response {
    $id = assertPOST('id');
    $db = connectDB();
    sqlTransaction($db, function($db) use ($id) {
        assert400(ToDo::exists($db, $id));
        ToDo::delete($db, $id);
    });
    return Response::redirect('/');
}

function onError(Exception $e) : Response {
    if ($e instanceof Exception400) {
        return Response::badRequest('400 Bad Request');
    } else if ($e instanceof Exception404) {
        return Response::notFound('404 Not Found');
    } else {
        return Response::internalServerError('500 Internal Server Error');
    }
}

$routesGET = [
    '/'    => 'todoList',
    '/add' => 'todoAdd',
];
$routesPOST = [
    '/add'    => 'post_todoAdd',
    '/delete' => 'post_todoDelete'
];

respond(getPathInfo(), $routesGET, $routesPOST, 'onError');


connectSQLite

SQLiteに接続する関数になります。

エラーを例外で送出、連想配列でデータを取得、静的プレースホルダーを利用するようにしています。

なお、データベースファイルは絶対パスで指定する必要があります。

<?php
function connectSQLite(string $name) : PDO {
    $db = new PDO("sqlite:{$name}");
    $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
    $db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
    $db->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
    return $db;
}


sqlTransaction

トランザクションを毎回記述するのを回避するため、callableを受け取る関数を定義しました。

<?php
function sqlTransaction(PDO $db, callable $f) : void {
    try {
        $db->beginTransaction();
        $f($db);
        $db->commit();
    } catch (Exception $e) {
        $db->rollBack();
        throw $e;
    }
}


Validate

POSTパラメータの検証の記述量削減のため、下記のようなクラスを準備してみました。

<?php
class Validate {
    public $messages = [];
    public function isTrue(bool $condition, string $message) : void {
        if ($condition === false) {
            array_push($this->messages, $message);
        }
    }
}

ついでにException400を送出するassert関数も定義してみました。

<?php
function assert400(bool $condition) {
    if ($condition === false) {
        throw new Exception400();
    }
}
function assertPOST(string $key) {
    if (isset($_POST[$key])) {
        return $_POST[$key];
    } else {
        throw new Exception400();
    }
}