本日も乙

ただの自己満足な備忘録。

StofDoctrineExtensionsBundle について使ったり調べてみた

この記事は、Symfony Advent Calendar 2014 - Qiitaの7日目の記事です。

Symfony2で開発していると大抵の人がお世話になっている、StofDoctrineExtensionsBundleですが、Timestampable(作成日や更新日を自動挿入できる)以外にも便利な機能が提供されていたので、つかってみました。

StofDoctrineExtensionsBundle とは

簡単に言うと、O/RマッパーであるDocrtineを拡張したDoctrineExtensionsを簡単に使えるようにバンドル化したのが、StofDoctrineExtensionsBundleです。

作成日や更新日を自動で挿入してくれるTimestampableを使っている人も多いかと思いますが、ドキュメントを見ると色々な機能を提供していることがわかります。

DoctrineExtensions's featuresを見ると、次のような機能があります。
一部(というかほとんど)よくわかっていない機能があります。

Tree

  • 木構造のような扱いをしてくれる。カテゴリなどに向いている

Translatable

  • ロケール(Locale)を設定することで他言語で挿入したデータを取得できる

Sluggable

  • スラッグ化(?)した文字列を取得できる
  • e.g. "the title", "my code" => "the-title-my-code"

Timestampable

  • 作成日(CreatedAt)や更新日(UpdatedAt)を自動で挿入・変更してくれる
  • (おそらく)使用頻度No.1

Blameable

  • エンティティを新規作成、更新すると別のエンティティの要素を更新してくれる(?)
  • Timestampableと似ているが、文字列やオブジェクトなどを挿入してくれる

Loggable

  • エンティティに変更履歴を残すことができ、昔のバージョンに戻すこともできる

Sortable

  • エンティティのソートキーを更新してくれる
  • Todoリストみたいに順番(position)を管理したい場合に便利

Translator

  • Translatable とどう違うのかがよくわからない

Softdeleteable

  • $em->remove()したときに、削除日(deletedAt)に日付を入れることでソフトデリートしてくれる

Uploadable

  • ファイルのアップロードやリネーム、移動などをサポートしてくれる

Reference Integrity

  • MongoDB用にエンティティ間でデータの整合性を保つのをサポートしてくれる

実際に使ってみた

Timestampable, Softdeleteable, Sortableを使ってみたので使い方をざっと見ていきます。
リポジトリはこちら ⇒ ohsawa0515/simulist (develop branch)

ER図

こんな感じです。すごくシンプルです。
カラム名やテーブル名が複数形になったりならなかったりしていますが気にしないでください。

er_todolist

  • Projectテーブル ・・・ Todoリストを管理する
  • Listsテーブル ・・・ タスク

一応、SQLも載せておきます。

CREATE TABLE IF NOT EXISTS `project` (
  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `name` VARCHAR(300) NOT NULL COMMENT 'プロジェクト名',
  `identify` VARCHAR(100) NOT NULL COMMENT 'URLに付与する識別子',
  `start` DATETIME NULL COMMENT '開始日',
  `finish` DATETIME NULL COMMENT '終了日',
  `status` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0b0000:未着手 0b0001:完了 0b0010:実行中',
  `created_at` DATETIME NOT NULL,
  `created_by` INT UNSIGNED NOT NULL DEFAULT 0,
  `updated_at` DATETIME NOT NULL,
  `updated_by` INT UNSIGNED NOT NULL DEFAULT 0,
  `deleted_at` DATETIME NULL,
  PRIMARY KEY (`id`),
  UNIQUE INDEX `identify_UNIQUE` (`identify` ASC))
ENGINE = InnoDB
COMMENT = 'プロジェクト';

CREATE TABLE IF NOT EXISTS `lists` (
  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `project_id` BIGINT UNSIGNED NOT NULL,
  `member_id` INT UNSIGNED NULL,
  `todo` VARCHAR(300) NOT NULL,
  `position` INT UNSIGNED NULL COMMENT '順番',
  `priority` TINYINT UNSIGNED NULL DEFAULT 0 COMMENT '優先度',
  `time_limit` DATETIME NULL COMMENT '期日',
  `status` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0b0000:未着手 0b0001:完了 0b0010:実行中',
  `created_at` DATETIME NOT NULL,
  `created_by` INT UNSIGNED NOT NULL DEFAULT 0,
  `updated_at` DATETIME NOT NULL,
  `updated_by` INT UNSIGNED NOT NULL DEFAULT 0,
  `deleted_at` DATETIME NULL,
  PRIMARY KEY (`id`),
  INDEX `fk_list_project_idx` (`project_id` ASC),
  INDEX `fk_list_member1_idx` (`member_id` ASC),
  CONSTRAINT `fk_list_project`
    FOREIGN KEY (`project_id`)
    REFERENCES `project` (`id`)
    ON DELETE NO ACTION
    ON UPDATE NO ACTION,
  CONSTRAINT `fk_list_member1`
    FOREIGN KEY (`member_id`)
    REFERENCES `member` (`id`)
    ON DELETE NO ACTION
    ON UPDATE NO ACTION)
ENGINE = InnoDB
COMMENT = 'Todo';

Composerでインストール

DoctrineExtensionsBundleをComposerでインストールします。 DataFixuresBundleも後で使うので、一緒にインストールします。

// composer.json
{
    ...
    "require": {
        ...,
        "stof/doctrine-extensions-bundle": "1.1.*",
        "doctrine/doctrine-fixtures-bundle": "2.2.*",
    }
}
$ composer install

バンドルの設定

AppKernel.phpでバンドルを有効化させます。

// app/AppKernel.php
public function registerBundles()
{
    return array(
        // ...
        new Stof\DoctrineExtensionsBundle\StofDoctrineExtensionsBundle(),
        new Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle(),
        // ...
    );
}

app/config/config.ymlで使いたい機能を設定します。

--- app/config/old_config.yml
+++ app/config/new_config.yml

@@ -59,6 +60,19 @@
     orm:
         auto_generate_proxy_classes: "%kernel.debug%"
         auto_mapping: true
+        filters:
+            softdeleteable:
+                class: Gedmo\SoftDeleteable\Filter\SoftDeleteableFilter
+                enabled: true
+
+# Doctrine Extension
+stof_doctrine_extensions:
+    default_locale: ja_JP
+    orm:
+        default:
+            timestampable: true
+            softdeleteable: true
+            sortable: true

エンティティの設定

すでに各エンティティクラスが自動生成している前提で、エンティティクラスにアノテーションで機能を有効化させます。

<?php

--- Entity/old_Project.php
+++ Entity/new_Project.php
@@ -3,12 +3,14 @@
 use Doctrine\ORM\Mapping as ORM;
+use Gedmo\Mapping\Annotation as Gedmo;

 /**
  * Project
  *
  * @ORM\Table(name="project", uniqueConstraints={@ORM\UniqueConstraint(name="identify_UNIQUE", columns={"identify"})})
  * @ORM\Entity
+ * @Gedmo\SoftDeleteable(fieldName="deletedAt")
  */
 class Project
 {
@@ -60,6 +62,7 @@
      * @var \DateTime
      *
      * @ORM\Column(name="created_at", type="datetime", nullable=false)
+     * @Gedmo\Timestampable(on="create")
      */
     private $createdAt;

@@ -74,6 +77,7 @@
      * @var \DateTime
      *
      * @ORM\Column(name="updated_at", type="datetime", nullable=false)
+     * @Gedmo\Timestampable(on="update")
      */
     private $updatedAt;
  • $createdAtは作成日、$updatedAtは更新日、$deletedAtは削除日(ソフトデリート)で設定しています
<?php

--- Entity/old_Lists.php
+++ Entity/new_Lists.php

@@ -3,18 +3,20 @@
 use Doctrine\ORM\Mapping as ORM;
+use Gedmo\Mapping\Annotation as Gedmo;

 /**
  * Lists
  *
  * @ORM\Table(name="lists", indexes={@ORM\Index(name="fk_list_project_idx", columns={"project_id"}), @ORM\Index(name="fk_list_member1_idx", columns={"member_id"})})
  * @ORM\Entity
+ * @Gedmo\SoftDeleteable(fieldName="deletedAt")
  */
 class Lists
 {
@@ -27,14 +29,15 @@
      * @ORM\Column(name="todo", type="string", length=300, nullable=false)
      */
     private $todo;

     /**
      * @var integer
      *
+     * @Gedmo\SortablePosition
      * @ORM\Column(name="position", type="integer", nullable=true)
      */
     private $position;
@@ -56,28 +59,30 @@
      */
     private $status = '0';

     /**
      * @var \DateTime
      *
      * @ORM\Column(name="created_at", type="datetime", nullable=false)
+     * @Gedmo\Timestampable(on="create")
      */
     private $createdAt;

     /**
      * @var integer
      *
      * @ORM\Column(name="created_by", type="integer", nullable=false)
      */
     private $createdBy = '0';

     /**
      * @var \DateTime
      *
      * @ORM\Column(name="updated_at", type="datetime", nullable=false)
+     * @Gedmo\Timestampable(on="update")
      */
     private $updatedAt;
@@ -90,14 +95,15 @@
      * @ORM\Column(name="deleted_at", type="datetime", nullable=true)
      */
     private $deletedAt;

     /**
      * @var \Project
      *
+     * @Gedmo\SortableGroup
      * @ORM\ManyToOne(targetEntity="Project")
      * @ORM\JoinColumns({
      *   @ORM\JoinColumn(name="project_id", referencedColumnName="id")
      * })
      */
     private $project;
  • $createdAtは作成日、$updatedAtは更新日、$deletedAtは削除日(ソフトデリート)で設定しています
  • $positionはソートする際に、順番を入れます
  • $project@Gedmo\SortableGroupを設定することで、Project単位でListsのソートを設定してくれます

DataFixtureでデータを入れる

追加した機能が正しく動いているかはDataFixturesを使って確認します。

<?php
// DataFixtures/ORM/LoadProjectData.php

namespace Shu1\SimulistBundle\Datafixtures\ORM;

use Doctrine\Common\DataFixtures\AbstractFixture;
use Doctrine\Common\DataFixtures\OrderedFixtureInterface;
use Doctrine\Common\Persistence\ObjectManager;
use Shu1\SimulistBundle\Entity\Project;
use Symfony\Component\DependencyInjection\ContainerAwareInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * LoadProjectData Class
 *
 */
class LoadProjectData extends AbstractFixture implements OrderedFixtureInterface, ContainerAwareInterface
{
    /**
     *
     * @var ContainerInterface
     */
    private $container;

    /**
     * (non-PHPdoc)
     * @see Symfony\Component\DependencyInjection.ContainerAwareInterface::setContainer()
     */
    public function setContainer(ContainerInterface $container = null)
    {
        $this->container = $container;
    }

    /**
     * @param ObjectManager $manager
     */
    public function load(ObjectManager $manager)
    {
        $entityManager = $this->container->get('doctrine')->getManager();

        // auto incrementのリセット
        $connection = $entityManager->getConnection();
        $connection->exec('ALTER TABLE project AUTO_INCREMENT = 1;');

        $project = new Project();
        $project->setName('買い物リスト');
        $project->setIdentify('abcdefg');
        $manager->persist($project);
        $manager->flush();
        $this->addReference('shopping_list', $project);

        $project = new Project();
        $project->setName('俺のタスク');
        $project->setIdentify('hijklmn');
        $manager->persist($project);
        $manager->flush();
        $this->addReference('oreno_task', $project);
    }

    /**
     * @return int
     */
    public function getOrder()
    {
        return 1;
    }
} 
<?php
// DataFixtures/ORM/LoadListsData.php

namespace Shu1\SimulistBundle\Datafixtures\ORM;

use Doctrine\Common\DataFixtures\AbstractFixture;
use Doctrine\Common\DataFixtures\OrderedFixtureInterface;
use Doctrine\Common\Persistence\ObjectManager;
use Shu1\SimulistBundle\Entity\Lists;
use Symfony\Component\DependencyInjection\ContainerAwareInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * LoadListsData Class
 *
 */
class LoadListsData extends AbstractFixture implements OrderedFixtureInterface, ContainerAwareInterface
{
    /**
     *
     * @var ContainerInterface
     */
    private $container;

    /**
     * (non-PHPdoc)
     * @see Symfony\Component\DependencyInjection.ContainerAwareInterface::setContainer()
     */
    public function setContainer(ContainerInterface $container = null)
    {
        $this->container = $container;
    }

    /**
     * @param ObjectManager $manager
     */
    public function load(ObjectManager $manager)
    {
        $entityManager = $this->container->get('doctrine')->getManager();

        // auto incrementのリセット
        $connection = $entityManager->getConnection();
        $connection->exec('ALTER TABLE lists AUTO_INCREMENT = 1;');

        $lists = new Lists();
        $lists->setProject($this->getReference('shopping_list'));
        $lists->setTodo('お茶ペットボトル500ml');
        $manager->persist($lists);
        $manager->flush();

        $lists = new Lists();
        $lists->setProject($this->getReference('shopping_list'));
        $lists->setTodo('味噌');
        $manager->persist($lists);
        $manager->flush();

        $lists = new Lists();
        $lists->setProject($this->getReference('shopping_list'));
        $lists->setTodo('ネギ');
        $manager->persist($lists);
        $manager->flush();

        $lists = new Lists();
        $lists->setProject($this->getReference('shopping_list'));
        $lists->setTodo('白菜');
        $manager->persist($lists);
        $manager->flush();

        $lists = new Lists();
        $lists->setProject($this->getReference('shopping_list'));
        $lists->setTodo('豚肉');
        $manager->persist($lists);
        $manager->flush();

        $lists = new Lists();
        $lists->setProject($this->getReference('oreno_task'));
        $lists->setTodo('部屋を掃除する');
        $manager->persist($lists);
        $manager->flush();

        $lists = new Lists();
        $lists->setProject($this->getReference('oreno_task'));
        $lists->setTodo('転居ハガキを作成する');
        $lists->setTimeLimit(new \DateTime('2014-10-31 12:00'));
        $manager->persist($lists);
        $manager->flush();

        $lists = new Lists();
        $lists->setProject($this->getReference('oreno_task'));
        $lists->setTodo('DVDを返却する');
        $lists->setTimeLimit(new \DateTime('2014-11-1 10:00'));
        $manager->persist($lists);
        $manager->flush();
    }

    /**
     * @return int
     */
    public function getOrder()
    {
        return 2;
    }
} 

データをロードします。

php app/console doctrine:fixtures:load --env dev
Careful, database will be purged. Do you want to continue Y/N ?y
  > purging database
  > loading [1] Shu1\SimulistBundle\Datafixtures\ORM\LoadProjectData
  > loading [2] Shu1\SimulistBundle\Datafixtures\ORM\LoadListsData

データが格納されたか確認します。

mysql> select lists.id, project.identify, project.name, todo, position, lists.created_at, lists.updated_at, lists.deleted_at from lists inner join project on lists.project_id = project.id order by project_id, position;
+----+----------+--------------------+--------------------------------+----------+---------------------+---------------------+------------+
| id | identify | name               | todo                           | position | created_at          | updated_at          | deleted_at |
+----+----------+--------------------+--------------------------------+----------+---------------------+---------------------+------------+
|  1 | abcdefg  | 買い物リスト       | お茶ペットボトル500ml          |        0 | 2014-12-05 15:03:13 | 2014-12-05 15:03:13 | NULL       |
|  2 | abcdefg  | 買い物リスト       | 味噌                           |        1 | 2014-12-05 15:03:13 | 2014-12-05 15:03:13 | NULL       |
|  3 | abcdefg  | 買い物リスト       | ネギ                           |        2 | 2014-12-05 15:03:13 | 2014-12-05 15:03:13 | NULL       |
|  4 | abcdefg  | 買い物リスト       | 白菜                           |        3 | 2014-12-05 15:03:13 | 2014-12-05 15:03:13 | NULL       |
|  5 | abcdefg  | 買い物リスト       | 豚肉                           |        4 | 2014-12-05 15:03:13 | 2014-12-05 15:03:13 | NULL       |
|  6 | hijklmn  | 俺のタスク         | 部屋を掃除する                 |        0 | 2014-12-05 15:03:13 | 2014-12-05 15:03:13 | NULL       |
|  7 | hijklmn  | 俺のタスク         | 転居ハガキを作成する           |        1 | 2014-12-05 15:03:13 | 2014-12-05 15:03:13 | NULL       |
|  8 | hijklmn  | 俺のタスク         | DVDを返却する                  |        2 | 2014-12-05 15:03:13 | 2014-12-05 15:03:13 | NULL       |
+----+----------+--------------------+--------------------------------+----------+---------------------+---------------------+------------+
8 rows in set (0.00 sec)

created_at,updated_atに日付が挿入されているのが分かります。また、positionもデータ投入した順にインクリメントされているのが確認できます。

SoftDeleteableは以下のようなコードを実行することで確認できます。

<?php

public function deleteAction(Request $request)
{
    $id       = '2';
    $identify = 'abcdefg';

    $entityManager = $this->getDoctrine()->getManager();
    $queryBuilder  = $entityManager->createQueryBuilder();
    try {
        $queryBuilder
            ->select('l, p')
            ->from('Shu1SimulistBundle:Lists', 'l')
            ->innerJoin('l.project', 'p')
            ->where('p.identify = :identify')
            ->andWhere('l.id = :id')
            ->setParameter('identify', $identify)
            ->setParameter('id', $id);
        $list = $queryBuilder->getQuery()->getSingleResult();
    } catch (\Exception $exception) {
        $this->get('logger')->error($exception->getMessage());

        return new Response('ng', 404);
    }

    // 見つからない場合
    if (!$list) {
        return new Response('ng', 404);
    }

    $entityManager->remove($list);
    $entityManager->flush();

    return new Response('ok', 200);
}

実行すると、idが2のタスク(味噌)のdeleted_atに日付が入ります。

mysql> select lists.id, project.identify, project.name, todo, position, lists.created_at, lists.updated_at, lists.deleted_at from lists inner join project on lists.project_id = project.id order by project_id, position;
+----+----------+--------------------+--------------------------------+----------+---------------------+---------------------+---------------------+
| id | identify | name               | todo                           | position | created_at          | updated_at          | deleted_at          |
+----+----------+--------------------+--------------------------------+----------+---------------------+---------------------+---------------------+
|  1 | abcdefg  | 買い物リスト       | お茶ペットボトル500ml          |        0 | 2014-12-05 15:03:13 | 2014-12-05 15:03:13 | NULL                |
|  2 | abcdefg  | 買い物リスト       | 味噌                           |        1 | 2014-12-05 15:03:13 | 2014-12-05 15:03:13 | 2014-12-05 15:03:46 |
|  3 | abcdefg  | 買い物リスト       | ネギ                           |        2 | 2014-12-05 15:03:13 | 2014-12-05 15:03:13 | NULL                |
|  4 | abcdefg  | 買い物リスト       | 白菜                           |        3 | 2014-12-05 15:03:13 | 2014-12-05 15:03:13 | NULL                |
|  5 | abcdefg  | 買い物リスト       | 豚肉                           |        4 | 2014-12-05 15:03:13 | 2014-12-05 15:03:13 | NULL                |
|  6 | hijklmn  | 俺のタスク         | 部屋を掃除する                 |        0 | 2014-12-05 15:03:13 | 2014-12-05 15:03:13 | NULL                |
|  7 | hijklmn  | 俺のタスク         | 転居ハガキを作成する           |        1 | 2014-12-05 15:03:13 | 2014-12-05 15:03:13 | NULL                |
|  8 | hijklmn  | 俺のタスク         | DVDを返却する                  |        2 | 2014-12-05 15:03:13 | 2014-12-05 15:03:13 | NULL                |
+----+----------+--------------------+--------------------------------+----------+---------------------+---------------------+---------------------+
8 rows in set (0.00 sec)

最後に

StofDoctrineExtensionsBundleを設定することで細かい制御をしなくても済むのでとても便利です。
他の機能を使う機会があればまたブログに書きたいと思います。