SensioFrameworkExtraBundle@ParamConverterアノテーション はSymfony2を利用した開発で最も多用する機能のうちのひとつではないでしょうか? 当エントリではこの機能にビルトインされているDoctrine Converterについての基本的な動作をケース・スタディとして確認したいと思います。

前提

あるアプリケーションにおいて以下の前提が成り立つとします。

  • ユーザが存在する。
  • ユーザは一意のnameという属性を持つ。
  • ユーザは複数の画像ファイルを保有でき、そのうちひとつをプロフィール画像として指定できる。
  • ユーザは住所情報を登録し、異なるユーザが同一の住所を登録することもできる。

このとき、エンティティを反映したDBのテーブルが以下のようだとします。

mysql> show create table user;
CREATE TABLE "user" (
  "id" int(11) NOT NULL AUTO_INCREMENT,
  "address_id" int(11) DEFAULT NULL,
  "image_id" int(11) NOT NULL,
  "name" varchar(255) COLLATE utf8_unicode_ci NOT NULL,
  PRIMARY KEY ("id"),
  UNIQUE KEY "UNIQ_8D93D6495E237E06" ("name"),
  UNIQUE KEY "UNIQ_8D93D6493DA5256D" ("image_id"),
  KEY "IDX_8D93D649F5B7AF75" ("address_id"),
  CONSTRAINT "FK_8D93D6493DA5256D" FOREIGN KEY ("image_id") REFERENCES "image" ("id"),
  CONSTRAINT "FK_8D93D649F5B7AF75" FOREIGN KEY ("address_id") REFERENCES "address" ("id")
)

mysql> show create table image;
CREATE TABLE "image" (
  "id" int(11) NOT NULL AUTO_INCREMENT,
  "user_id" int(11) DEFAULT NULL,
  PRIMARY KEY ("id"),
  KEY "IDX_C53D045FA76ED395" ("user_id"),
  CONSTRAINT "FK_C53D045FA76ED395" FOREIGN KEY ("user_id") REFERENCES "user" ("id")
)

mysql> show create table address;
CREATE TABLE "address" (
  "id" int(11) NOT NULL AUTO_INCREMENT,
  "zip" varchar(255) COLLATE utf8_unicode_ci NOT NULL,
  PRIMARY KEY ("id"),
  UNIQUE KEY "UNIQ_D4E6F81421D9546" ("zip")
)


mysql> select * from user;
+----+------------+----------+--------+
| id | address_id | image_id | name   |
+----+------------+----------+--------+
|  1 |          1 |        1 | user_1 |
|  2 |          1 |        3 | user_2 |
+----+------------+----------+--------+
2 rows in set (0.00 sec)

mysql> select * from image;
+----+---------+
| id | user_id |
+----+---------+
|  1 |       1 |
|  2 |       1 |
|  3 |       2 |
+----+---------+
3 rows in set (0.00 sec)

mysql> select * from address;
+----+-----+
| id | zip |
+----+-----+
|  1 | 100 |
+----+-----+
1 row in set (0.00 sec)

IDでユーザを指定

まずは、idを指定してのリクエスト(ex. GET /1)を実装してみます。

1. /{id}

<?php

use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;

/**
 * @Route("/{id}")
 * @Method("GET")
 * @ParamConverter("user", class="AppBundle:User")
 */
public function getUserByIdAction($user)
{
}

@ParamConverter("user", class="AppBundle:User") における"user"は該当コントローラのアクションメソッドの引数名、class=で指定されているクラスはコンバート対象のクラスとなります。

ここでGET /1というリクエストを送った場合には、Routeに指定された{id}をキーにリソースを特定しAppBundle:Userオブジェクトを生成してgetUserByIdActionメソッドの$userとして注入されます。

GET /2というリクエストを送った場合には、user.id=2のユーザは存在しないのでステータスコード404のHTTPレスポンスが返ります。

このリクエスト時に走っているクエリは以下です。プライマリキーで探索しているのがわかりますね。

SELECT * FROM user WHERE id = 1;

なお、コントローラアクションのシグネチャとしてタイプヒンティングを採用することで@ParamConverterアノテーションは省略することができます。コードがシンプルになりますね。

<?php

use AppBundle\Entity\User;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;

/**
 * @Route("/{id}")
 * @Method("GET")
 */
public function getUserByIdAction(User $user)
{
}

2. /{user_id} にしてみる

ルートリソースの指定パラメータ名を{id}から{user_id}にしてみます。コーディング時の視認性の良さがメリットですかね。(以降、use文は割愛します)

<?php

/**
 * @Route("/{user_id}")
 * @Method("GET")
 */
public function getUserByIdAction(User $user)
{
}

Unable to guess how to get a Doctrine instance from the request information. というエラーメッセージが表示されました。どうやらこの定義では情報が足りないようです。 ここで{user_id}Userエンティティのプライマリキーidであることを教えてやります。

<?php

/**
 * @Route("/{user_id}")
 * @Method("GET")
 * @ParamConverter("user", options={"id" = "user_id"})
 */
public function getUserByIdAction(User $user)
{
}

これで正常に取得できました。

nameでユーザを指定

次に、属性nameを指定してのリクエスト(ex. GET /user_1)を実装してみます。URLに数字のIDが指定されているよりもヒューマンフレンドリですよね。

1. /{name}

<?php

/**
 * @Route("/{name}")
 * @Method("GET")
 */
public function getUserByNameAction(User $user)
{
}

正常に取得できました。ここで走っているクエリは以下です。

SELECT * FROM user WHERE name = "user_1" LIMIT 1;

LIMIT 1となっていますね。idを指定した場合とは異なっています。ここで仮に住所を指定することにしてみます。

<?php

/**
 * @Route("/{address}")
 * @Method("GET")
 */
public function getUserByAddressAction(User $user)
{
}

user_1の情報が取得できました。ここで走っているクエリは以下です。

SELECT * FROM user WHERE address_id = 1 LIMIT 1;

しかしそもそもこれは設計がおかしいですね。user_1user_2も住所1に住んでいます。どちらのユーザかを特定できる情報はURL上にはありません。たとえばこのURLならユーザのリストを取得するように当該アクションのシグネチャはpublic function getUsersByAddressAction(array $users)といった感じになるべきです。

設計がおかしいことをParamConverterが「Userエンティティaddressプロパティではユニークじゃないよ!」とエラーで教えてくれると良いのですがそのような仕組みになっていません。これからもわかるようにid以外をキーとする際にはユニークになるものを指定するように設計には注意したいですね。

2. /{user_name} にしてみる

ルートリソースの指定パラメータ名を{name}から{user_name}にしてみます。{id}のときと同様ですので別名がどの属性を示すかを定義してやる必要がありますね。

<?php

/**
 * @Route("/{user_name}")
 * @Method("GET")
 * @ParamConverter("user", options={"name" = "user_name"})
 */
public function getUserByUserNameAction(User $user)
{
}

失敗です。Unable to guess how to get a Doctrine instance from the request information.と表示されてしまいました。 正しくは以下です。

<?php

/**
 * @Route("/{user_name}")
 * @Method("GET")
 * @ParamConverter("user", options={"mapping": {"user_name": "name"}})
 */
public function getUserByUserNameAction(User $user)
{
}

mappingオプションを用いて「Routeの{user_name}Userエンティティname属性にマップされますよ」と教えてやる必要があります。 さきほど「Routeの{user_id}Userエンティティidである」ことを@ParamConverter("user", options={"id" = "user_id"})として指定しましたよね。なぜ同様に指定している@ParamConverter("user", options={"name" = "user_name"})では動作しないのでしょう?

ここで"id"というのはUserエンティティid属性を意味するのではなく、あくまでidentityという意味として考えたほうがよさそうです。つまり「Userエンティティを特定するプライマリキー(=id)として{user_id}を使いますよ」という宣言だったわけです。ドキュメントにもprimary keyという文言があるのでidオプションではなくpkオプションとしたほうが混乱しなくて良さそうだなと感じました。

複数のエンティティを取得

次に、ユーザおよびユーザの保有する画像のエンティティを取得するためのリクエスト(ex. GET /users/user_1/images/1)を実装してみます。

<?php

/**
 * @Route("/users/{name}/images/{image}")
 * @Method("GET")
 */
public function getUserImageAction(User $user, Image $image)
{
}

こうしたときGET /users/user_1/images/1だと200が返ってきますがGET /users/user_1/images/2だとAppBundle\Entity\User object not found.というメッセージとともに404が返ってきます。 user_1のユーザも居ますしid=2の画像もあります。なぜでしょう?

この際に走っているクエリは以下です。

SELECT * FROM user WHERE name = "user_1" AND image_id = 2 LIMIT 1;

{name}{image}Userエンティティの特定のために使用されていることがわかります。Imageエンティティの取得は試みられていないみたいなので明示してやりましょう

<?php

/**
 * @Route("/users/{name}/images/{image}")
 * @Method("GET")
 * ParamConverter("image", options={"id" = "image"})
 */
public function getUserImageAction(User $user, Image $image)
{
}

AppBundle\Entity\User object not found.となりやっぱりだめですね。 この際に走っているクエリは以下。さきほどと変わりありません。

SELECT * FROM user WHERE name = "user_1" AND image_id = 2 LIMIT 1;

ここでmappingオプションを指定してやります。

<?php

/**
 * @Route("/users/{name}/images/{image}")
 * @Method("GET")
 * @ParamConverter("user", options={"mapping": {"name": "name"}})
 * @ParamConverter("image", options={"id" = "image"})
 */
public function getUserImageAction(User $user, Image $image)
{
}

うまくいきました。走っているクエリは以下。

SELECT * FROM user WHERE name = "user_1" LIMIT 1;
SELECT * FROM image WHERE id = 2;

なお、以下のようにImageエンティティに関する記述はなしでも動きます。

<?php

/**
 * @Route("/users/{name}/images/{image}")
 * @Method("GET")
 * @ParamConverter("user", options={"mapping": {"name": "name"}})
 */
public function getUserImageAction(User $user, Image $image)
{
}

また、以下のようにexcludeオプションとしてエンティティ特定に使用しないパラメータを指定することもできます。この例の場合は上記と下記は結果としては同義になりますね。

<?php

/**
 * @Route("/users/{name}/images/{image}")
 * @Method("GET")
 * @ParamConverter("user", options={"exclude": {"image"}})
 */
public function getUserImageAction(User $user, Image $image)
{
}

まとめ

Doctrine Converterでは以下の順でリソースの取得をトライします。

  1. {id}がRouteに指定されていたら、この値を用いてDBよりプライマリキーにより探索
  2. オプションでidが指定されていたら、この値を用いてDBよりプライマリキーにより探索
  3. どちらも適用されない場合には、Routeで指定されているパラメータを用いてDBよりリソースを1件取得

これらの動作を当エントリでは失敗ケースを含めて例示してみました。参考にしていただければ幸いです。

なお、Doctrine Converterでは今回の例で出てこなかった以下のオプションも存在します。詳細はドキュメントを御覧いただき活用いただければと思います。

オプション名 役割
entity_manager エンティティ・マネージャの指定
repository_method エンティティ取得時のリポジトリメソッドの指定
map_method_signature mappingオプションと併用することでエンティティ取得時のリポジトリメソッドのシグネチャで設定されている変数にRouteで指定されたパラメータがマッピングされる

参考