最近マルチテナントのシステムを開発することがあり、共通のDBをテナントごとに管理する方法を調べました。
結論
Doctrineの以下機能を使用するとCRUDの各SQLを改変でき、テナントを透過的に制御可能(すなわち毎回WHERE句にテナントの条件を書かなくて良い)。 但し、@OneToOneは期待通りに動作しないため要注意。環境
Doctrine 2.7.4Symfony 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));
}