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_1
もuser_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
では以下の順でリソースの取得をトライします。
{id}
がRouteに指定されていたら、この値を用いてDBよりプライマリキーにより探索- オプションで
id
が指定されていたら、この値を用いてDBよりプライマリキーにより探索 - どちらも適用されない場合には、Routeで指定されているパラメータを用いてDBよりリソースを1件取得
これらの動作を当エントリでは失敗ケースを含めて例示してみました。参考にしていただければ幸いです。
なお、Doctrine Converter
では今回の例で出てこなかった以下のオプションも存在します。詳細はドキュメントを御覧いただき活用いただければと思います。
オプション名 | 役割 |
---|---|
entity_manager | エンティティ・マネージャの指定 |
repository_method | エンティティ取得時のリポジトリメソッドの指定 |
map_method_signature | mappingオプション と併用することでエンティティ取得時のリポジトリメソッドのシグネチャで設定されている変数にRouteで指定されたパラメータがマッピングされる |