Do You PHP はてブロ

Do You PHPはてなからはてブロに移動しました

複合主キーを持つテーブルのCRUD

Symfony2/Doctrineのドキュメントは基本的に単一カラムを主キーとするテーブルが対象となっていて、複合主キーを持つテーブルに対する説明はかなり少なくて、あったとしてもサラっと流されてしまってる感じです。まあ、エラーメッセージでググれば情報は大概は出てくるんですが、情報があちこちに散らばってる状況です。
で、実際に複合主キーを持つテーブルに対するCRUDを作って、単一カラムの主キーの場合との違いをまとめてみました。
Bundleのソースコード一式はgithubに上げてあります。

対象とするテーブル

以下のようなカラムkey1、key2が主キーとなるテーブル(composite_keys)です。

CREATE TABLE composite_keys (
    key1 VARCHAR(2) NOT NULL,
    key2 VARCHAR(2) NOT NULL,
    name VARCHAR(10) NOT NULL
);

ALTER TABLE composite_keys
  ADD CONSTRAINT pk_composite_keys
      PRIMARY KEY (key1, key2);

Entityクラス

基本的に@ORM\Idアノテーションを複合主キーとなるカラムに対して記述するだけです。ただし、データベースのシーケンスを使った自動採番はできません。

<?php

namespace Acme\CompositePrimaryKeysBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * composite_keysクラス
 *
 * @version $Id$
 * @ORM\Table(name="composite_keys")
 * @ORM\Entity(repositoryClass="Acme\CompositePrimaryKeysBundle\Entity\CompositeKeysRepository")
 * @ORM\HasLifecycleCallbacks
 */
class CompositeKeys
{
    /**
     * @var string $key1 複合キー1
     *
     * @ORM\Id
     * @ORM\Column(name="key1", type="string", length=2, nullable=false)
     * @Assert\NotBlank(message="複合キー1は必須項目です")
     * @Assert\MaxLength(limit=2, message="複合キー1は{{ limit }}文字までです")
     * @Assert\Regex(pattern="#^[0-9]{2}$#", message="複合キー1の書式が正しくありません")
     */
    private $key1;

    /**
     * @var string $key2 複合キー2
     *
     * @ORM\Id
     * @ORM\Column(name="key2", type="string", length=2, nullable=false)
     * @Assert\NotBlank(message="複合キー2は必須項目です")
     * @Assert\MaxLength(limit=2, message="複合キー2は{{ limit }}文字までです")
     * @Assert\Regex(pattern="#^[0-9]{2}$#", message="複合キー2の書式が正しくありません")
     */
    private $key2;

    /**
     * @var string $name 名称
     *
     * @ORM\Column(name="name", type="string", length=10, nullable=false)
     * @Assert\NotBlank(message="名称は必須項目です")
     * @Assert\MaxLength(limit=10, message="名称は{{ limit }}文字までです")
     */
    private $name;
          :

ソースコードSymfony2_sample/CompositeKeys.php at master · shimooka/Symfony2_sample · GitHubです。

EntityRepository#findメソッド

引数にカラム名と値をペアとする連想配列を渡せます。以下、Controllerクラスでの例です。

<?php

namespace Acme\CompositePrimaryKeysBundle\Controller;
          :
class CompositeKeysController extends Controller
{/**
     * Finds and displays a CompositeKeys entity.
     *
     * @Route("/{key1}/{key2}/show", name="compositeKeys_show")
     * @Template()
     */
    public function showAction($key1, $key2)
    {/**
         * EntityRepository#findメソッドに配列で検索条件を渡す
         */
        $entity = $this->getRepository()
            ->find(array('key1' => $key1, 'key2' => $key2));
          :
    }private function getEntityManager() {
        return $this->getDoctrine()->getEntityManager();
    }
    private function getRepository($name = 'AcmeCompositePrimaryKeysBundle:CompositeKeys') {
        return $this->getEntityManager()->getRepository($name);
    }

ソースコードSymfony2_sample/CompositeKeysController.php at master · shimooka/Symfony2_sample · GitHubです。

KnpPaginatorBundleの対応

KnpPaginatorBundle便利ですね:-)
複合主キーを持つテーブルに対してKnpPaginatorBundleを使う場合、GitHub - KnpLabs/KnpPaginatorBundle: SEO friendly Symfony paginator to sort and paginateのUsage examplesにあるような

<?php
$em = $this->get('doctrine.orm.entity_manager');
$dql = "SELECT a FROM VendorBlogBundle:Article a";
$query = $em->createQuery($dql);

$paginator = $this->get('knp_paginator');
$pagination = $paginator->paginate(
    $query,
    $this->get('request')->query->get('page', 1)/*page number*/,
    10/*limit per page*/
);

というコードを実行すると

Single id is not allowed on composite primary key in entity

というエラーが発生します。
調べてみるとどうやら複合主キーを持つテーブルのサポートが充分でないようで、ちょっとトリッキーなことをする必要があるようです。

1. KnpPaginatorBundleのdistinctオプションを無効にする

distinctオプションを無効にするコードは以下のような感じです。サービスからキー"knp_paginator"を指定することで得られるKnp\Component\Pager\Paginatorオブジェクトのpaginateメソッドの第4引数にオプションを指定します。

<?php

namespace Acme\CompositePrimaryKeysBundle\Controller;
          :
class CompositeKeysController extends Controller
{/**
     * Lists all CompositeKeys entities.
     *
     * @Route("/list", name="compositeKeys_list")
     * @Template("AcmeCompositePrimaryKeysBundle:CompositeKeys:index.html.twig")
     */
    public function listAction()
    {$paginator = $this->get('knp_paginator');
        $pagination = $paginator->paginate(
            $query,
            $request->query->get('p', 1),   // $request->query = $_GET
            5,                              // items per page
            array('distinct' => false)      // 複合主キーの場合は必須
        );
          :
    }

ソースコードSymfony2_sample/CompositeKeysController.php at master · shimooka/Symfony2_sample · GitHubです。

2. Doctrine\ORM\Query#setHintメソッドに全件数をキー"knp_paginator.count"として与えてやる

Single id is not allowed · Issue #27 · KnpLabs/KnpPaginatorBundle · GitHubにもコードがありますが、同一条件で全件数を取得し、それをsetHintメソッドを使ってEntityを取得するクエリに渡してやります。この時のキーは"knp_paginator.count"とします。
以下、EntityRepositoryクラスの1メソッドにまとめてしまっていますが、やっていることは同じです。

<?php

namespace Acme\CompositePrimaryKeysBundle\Entity;

use Doctrine\ORM\EntityRepository;

class CompositeKeysRepository extends EntityRepository
{
    public function findBySearchForm(array $data, $get_entity = true) {
        $binds = array();
        $select_clause = 'a';
        $order_clause = "ORDER BY a.key1 ASC, a.key2 ASC ";
        if (!$get_entity) {
            $select_clause = 'COUNT(a)';
            $order_clause = null;
        }
        $query = "SELECT {$select_clause} FROM AcmeCompositePrimaryKeysBundle:CompositeKeys a "
          :
        $result = $this->getEntityManager()
            ->createQuery($query)
            ->setParameters($binds);

        if ($get_entity) {
            $result->setHint('knp_paginator.count', $this->findBySearchForm($data, false));
        } else {
            $result = $result->getSingleScalarResult();
        }
        return $result;
    }
}

ソースコードSymfony2_sample/CompositeKeysRepository.php at master · shimooka/Symfony2_sample · GitHubです。

まとめ

調査にかなり時間を取られてしまいましたが、複合主キーの場合でも思ったよりも難しくないようです。
これで作業を進められそうです:-)

おまけ:無理やりdoctrine:generate:crudCRUDを作る

おまけというか、これがやりたかったことなんですが、doctrine:generate:crudを使って簡単にマスターデータの管理画面を作れないか試行錯誤してました。
で、ちょっと強引&完全自動ではないですが、ざっと以下のような感じである程度対応できると思います。

  1. 単一カラムを主キーとするEntityクラスをとりあえず作る
  2. doctrine:generate:crudコマンドでCRUDをとりあえず作る
  3. Entityクラスの主キーを本来の複合主キーに変更する
  4. 生成されたControllerクラスを修正する
    • Routeアノテーションの"/{key1}/"を"/{key1}/{key2}/"に変更する
    • 各ActionメソッドとcreateDeleteFormメソッドの受け取る引数を$key1, $key2に変更する
    • EntityRepository#findメソッドの引数を正しく修正する
    • その他、引数に"key1"を指定している部分に"key2"を追加する
    • twigファイルも同様