Doctrine Custom Mapping Typeを定義して自作クラスをORM@Columnにマッピングする方法を紹介します。

Doctrine DBAL 2 documentation
http://docs.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/types.html#custom-mapping-types

この記事を書いた環境

  • Symfony 2.6
  • Doctrine/ORM 2.5
  • Doctrine/dbal 2.5

サンプルケース

月次レポートを示すReportエンティティ

Reportエンティティは月次レポートを管理するクラスです。年月をyyyymm形式の整数で持っています。

<?php

namespace MyProject\Bundle\AcmeDemoBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

class Report
{
	/**
	 * @var integer
	 * @ORM\Column(name="year_month", type="integer")
	 */
	private $yearMonth;

年月が扱いづらいので専門クラスに任せる

整数のままでは年月が扱いづらいので、専門クラスに任せましょう。

YearMonthクラス

phpmentors-jp YearMonth
https://github.com/phpmentors-jp/domain-commons/blob/master/src/DateTime/YearMonth.php

年月を管理するクラスです。 DailyTraitと組み合わせると日付をリストで取り出せるため、月次処理に持たせると非常に便利です。

composerで取り込んだYearMonthクラスを年月のプロパティに適用します。

$ composer require phpmentors/domain-commons
<?php

namespace MyProject\Bundle\AcmeDemoBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
+ use PHPMentors\DomainCommons\DateTime\YearMonth;

class Report
{
	/**
-	 * @var integer
+	 * @var YearMonth
	 * @ORM\Column(name="year_month", type="integer")
	 */
	private $yearMonth;

	public function __construct()
	{
+		$this->yearMonth = new YearMonth();
	}

ただ、このままではDBに保存する事はできません。

Object of class YearMonth could not be converted to int

/vendor/doctrine/dbal/lib/Doctrine/DBAL/DBALException.php:119
/vendor/doctrine/dbal/lib/Doctrine/DBAL/Statement.php:172
/vendor/doctrine/orm/lib/Doctrine/ORM/Persisters/Entity/BasicEntityPersister.php:281
/vendor/doctrine/orm/lib/Doctrine/ORM/UnitOfWork.php:1018
...

Custom Mapping Type

このような時に使用する手法がCustom Mapping Typeです。 変換方法を書いたクラスをdbal typeとして登録する事で、DBに保存・値を取り出しする時にDoctrineが呼び出してくれるようになります。

dbal typeを定義する

<?php

namespace MyProject\Budle\AcmeDemoBundle\Types;

use Doctrine\DBAL\Types\Type;

class YearMonthType extends Type
{
    // SQL内で宣言されるフィールド型 (注1)(注2)
    public function getSQLDeclaration(array $fieldDeclaration, AbstractPlatform $platform)
    {
        return 'integer';
    }

    // DB格納値をオブジェクトに変換
    public function convertToPHPValue($value, AbstractPlatform $platform)
    {
        return YearMonth::fromLong($value);
    }

    // オブジェクトをDB格納値に変換
    public function convertToDatabaseValue($value, AbstractPlatform $platform)
    {
        return $value->toLong();
    }

    // (注3)
    public function requiresSQLCommentHint(AbstractPlatform $platform)
    {
        return true;
    }

    public function getName()
    {
        return 'year_month_type';
    }
}

注釈1

Doctrineで定義されているフィールド型を使用します。 定義されている型、および実際にDBに適用されるフィールド型はMapping Matrixを参照してください。

Doctrine DBAL 2 documentation 8.2. Mapping Matrix
http://docs.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/types.html#mapping-matrix

注釈2

プラットフォーム(MySQL,Oracleなど)によって桁数が変わる場合などは検証をしておくと安全です。

<?php

public function getSQLDeclaration(array $fieldDeclaration, AbstractPlatform $platform)
{
	// プラットフォームが変わるとdoctrine:schema:updateの時に例外が発生する
	if ($platform !== 'mysql') {
		throw new \Exception('...');
   }
}

注釈3

requiresSQLCommentHint

PHPのネイティブオブジェクトはそのままDBに格納する事ができません。 このためDoctrineはカラムのコメントを利用してリバースエンジニアリングを行っています。 (コメントがないプラットフォームでは代わりにtext型のフィールドが適用されます)

This type will always be mapped to the database vendor’s text type internally as there is no way of storing a PHP object representation natively in the database. Furthermore this type requires a SQL column comment hint so that it can be reverse engineered from the database. Doctrine cannot map back this type properly on vendors not supporting column comments and will fall back to text type instead.

引用元 Doctrine DBAL 2 documentation 8.1.6.1. object

requiresSQLCommentHint はリバースエンジニアリングにコメントを使用するかを決めるメソッドです。 trueを返すことによりコメントにマッピング情報が書き込まれます。

+---------+----------+-----+---------+----------------+----------------------+
| Field   | Type     | Key | Default | Extra          | Comment              |
+---------+----------+-----+---------+----------------+----------------------+
| yyyymm  | int(11)  |     | NULL    |                | (DC2Type:year_month) |
+---------+----------+-----+---------+----------------+----------------------+

dbal typeを登録する

定義したtypeは自動で読み込まれません。config.ymlでdbal typeとして登録します。

# app/config/config.yml

doctrine:
	dbal:
		types:
			year_month_type: MyProject\Bundle\AcmeDemoBundle\Types\YearMonthType

エンティティに適用する

<?php

namespace MyProject\Bundle\AcmeDemoBundle\Entity;

class Report
{
	/**
	 * @var YearMonth
-	 * @ORM\Column(name="year_month", type="integer")
+	 * @ORM\Column(name="year_month", type="year_month_type")
	 */
	private $yearMonth;

これで、エンティティの取得時および保存時にDoctrineが型変換を行ってくれるようになりました。

フォームにも変換方法を教える

エンティティの変換はDoctrineが行ってくれるようになりましたがYearMonthクラスはそのままではフォームに表示する事ができません。フォームの場合はトランスフォーマを使って変換処理をします。

Symfony How to Use Data Transformers
http://symfony.com/doc/current/cookbook/form/data_transformers.html

トランスフォーマの定義

<?php

namespace MyProject\Bundle\AcmeDemoBundle\Form\DataTransformer;

use Symfony\Component\Form\DataTransformerInterface;

class YearMonthToNumberTransformer implements DataTransformerInterface
{
	// フォームに表示する値に変換
    public function transform($value)
    {
        return $value->toLong();
    }

	// 入力された値をオブジェクトに変換
    public function reverseTransform($value)
    {
        return YearMonth::fromLong($value);
    }
}

トランスフォーマをフォームに適用

<?php

namespace MyProject\Bundle\AcmeDemoBundle\Form\Type;

+ use MyProject\Bundle\AcmeDemoBundle\Form\DataTransformer\YearMonthToNumberTransformer;

class SampleType extends AbstractType
{
	public function buildForm(FormBuilderInterface $builder, array $options)
	{
	    $builder->add()
	    ...
+	    $builder->get('yearMonth')->addModelTransformer(new YearMonthToNumberTransformer());

スキーマ更新

最後にスキーマ更新をして終了です。

$app/console doctrine:schema:update --force

終わりに

例に紹介した通り、Custom Type Mappingを利用すればフィールドにマッピングしたプロパティにも外部クラスを導入する事ができます。実際YearMonthクラスを導入する事で、エンティティの中で定義していた細かい処理(バグ付き)をざっくり減らすことができました。

同じようなエンティティを定義している場合は、ぜひお試しください。