Doctrine SQL Filterでマルチテナントを実現する

こんにちは、岡野です。

最近マルチテナントのシステムを開発することがあり、共通のDBをテナントごとに管理する方法を調べました。

結論

Doctrineの以下機能を使用するとCRUDの各SQLを改変でき、テナントを透過的に制御可能(すなわち毎回WHERE句にテナントの条件を書かなくて良い)。 但し、@OneToOneは期待通りに動作しないため要注意。

環境

Doctrine 2.7.4
Symfony 4.4.16

SELECTの調査結果

以下の様なSQL Filterを登録。
class TenantFilter extends SQLFilter
{
    public function addFilterConstraint(ClassMetadata $targetEntity, $targetTableAlias): string
    {
        // 100は実際には可変(後述)
        return $targetTableAlias.'.tenant_id = 100';
    }
}

単純なSELECT

$em->find(Parent::class, 1);
→ SELECT ... FROM parent WHERE id = 1 AND tenant_id = 100;

@OneToMany

$em->find(Parent::class, 1)->getChilds();
→ SELECT ... FROM parent WHERE id = 1 AND tenant_id = 100;
→ SELECT ... FROM child WHERE parent_id = 1 AND tenant_id = 100;

@ManyToOne

@OneToMany同様。

@ManyToMany

@OneToMany同様。

@OneToOne

$em->find(Example1::class, 1)->getExample2();
→ SELECT ... FROM example1 e1 LEFT JOIN example2 e2 ON e2.example1_id = e1.id WHERE e1.id = 1 AND e1.tenant_id = 100;
⇒ example2テーブルへのtenant_id = 100が存在しない

join()

$em->createQueryBuilder('c')->join('c.parent')->where('c.id = 1');
→ SELECT ... FROM child c JOIN parent p ON c.parent_id = p.id AND p.tenant_id = 100 WHERE c.id = 1 AND c.tenant_id = 100;

leftJoin()

join()同様。



INSERT/UPDATE/DELETEの調査結果

以下の様なDoctrine Event Listenerを登録。
class TenantListener
{
    public function prePersist(LifecycleEventArgs $args): void
    {
        assert($args->getEntity()->getTenant() === null);
        // 100は実際には可変
        $args->getEntity()->setTenant($this-em->find(Tenant::class, 100));
    }
    public function preUpdate(LifecycleEventArgs $args): void
    {
        // 安全のため他テナントを操作していないかチェックする
        if ($args->getEntity()->getTenant()->getId() !== 100) {
            throw new \LogicException();
        }
    }
    public function preRemove(LifecycleEventArgs $args): void
    {
        // preUpdate()同様
    }
}

INSERT

$em->persist(new Example());
→ INSERT INTO example (..., tenant_id) VALUES (..., 100);


その他

上記のようにDoctrine FilterのaddFilterConstraint()内でtenant_idを直接設定するとDoctrine Query CacheによりSQLが固定化されてしまい期待通りに動作しない。そのため以下の通りparameterを経由する必要があった。

// 外部から呼ぶ(RequestListenerなどから)
public function setTenantId(int $tenantId): void
{
    $this->setParameter('tenant_id', $tenantId, Types::INTEGER);
}

public function addFilterConstraint(ClassMetadata $targetEntity, $targetTableAlias): string
{
    $tenantId = (int)$this->unquoteParameterValue($this->getParameter('tenant_id'));
    return $targetTableAlias.'.tenant_id = '.$tenantId;
}

private function unquoteParameterValue(string $value): string
{
    $len = strlen($value);
    if ($value[0] !== "'" || $value[$len - 1] !== "'") {
        throw new \LogicException($value);
    }
    return str_replace(['\\"', '\\\\'], ['"', '\\'], substr($value, 1, $len - 2));
}