TypeORM SelectQueryBuilderでJoinとwithDeletedの挙動

TypeORMでSelectQueryBuilderをよく使うのですが、論理削除されたレコードを取得する`withDeleted`メソッドについてのドキュメントがシンプルすぎてテーブルをJOINしたときの挙動がよくわからなかったので実際に動かして調べてみました。

**※ 動かしてみてきっとこうだろうと推測される内容を書いてます。この動作が保証されるわけではないです。**

# 前提

## TypeORM

TypeORM 0.2.31 で確認しました。
[Changelog](https://typeorm.io/changelog)を見る限り、これ以降0.3.9までの間で特に変更入ってないようです

SelectQueryBuilderのwithDeletedメソッドについてドキュメントで見つけられたのは[ここ](https://typeorm.io/select-query-builder#querying-deleted-rows)だけでした。他にあるのかなあ？

## DB

他のものでもそんなに変わらないと思うけど、手元で試したのはPostgreSQLです。


## 例として扱うテーブルたち

```
companyテーブル（会社）
  departmentテーブル（部署）
    memberテーブル（署員）
clientテーブル（顧客）
```
- companyとdepartment、departmentとmember、companyとclientはそれぞれ1対多のリレーションを持つことにします。
- Entityはそれぞれ、Company、Department、Member、Clientとします。
    - Entityの定義はさきほどの[ドキュメント](https://typeorm.io/select-query-builder#querying-deleted-rows)のように定義されていることにします。

# やってみた

## 1種類のEntity(Company)を取得する場合

この場合はドキュメントに書かれているのと同じなのですが、Companyで論理削除されたものも含めてすべて取得しようとすると

```TypeScript
const companies = await dataSource
  .getRepository(Company)
  .createQueryBuilder('company')
  .select('company.id', 'id')
  .withDeleted()
  .getMany();
```
のようになります。

## JOINする場合

ここからが本題です。

### CompanyとDepartmentをJOINする場合

前述の通り、CompanyとDepartmentは1対多です。

#### getMany直前にwithDeletedメソッドを追加

どういう動きをするのか全然わからなかったので、とりあえずこうしてみました。

```TypeScript
const companies = await dataSource
  .getRepository(Company)
  .createQueryBuilder('company')
  .select('company.id', 'id')
  .leftJoinAndSelect('company.departments', 'department')
  .withDeleted()
  .getMany();
```

結果は次のような組み合わせになって、Companyは論理削除に関わらず取得されますが、Departmentは論理削除されたものは取得できませんでした。
お？Department全部取れてこないじゃんと混乱したのはいい思い出。

|Company<br/>論理削除|Department<br/>論理削除|Company<br/>取得|Department<br/>取得|
|--|--|--|--|
|-|-|○|○|
|-|○|○|-|
|○|-|○|○|
|○|○|○|-|

※ 論理削除されたものが`○`、されていないものが`-`。取得できたものが`○`、されなかったものが`-`。

#### JOIN直前にwithDeletedメソッドを追加

ここで、「もしかしてCompanyにしかwithDeleted効かない？」と思ったのですが、諦めが悪いので順番を変えて突っ込むことにしました。

```TypeScript
const companies = await dataSource
  .getRepository(Company)
  .createQueryBuilder('company')
  .select('company.id', 'id')
  .withDeleted()
  .leftJoinAndSelect('company.departments', 'department')
  .getMany();
```

結果は次のような組み合わせになって、Departmentも論理削除に関わらず取得されました。

|Company<br/>論理削除|Department<br/>論理削除|Company<br/>取得|Department<br/>取得|
|--|--|--|--|
|-|-|○|○|
|-|○|○|○|
|○|-|○|○|
|○|○|○|○|

どうやら`withDeleted`の位置によって論理削除でも取得される・されないが変わりそうというのはわかってきました。

### CompanyとDepartmentとMemberをJOINする場合

それじゃあ、3つJOINしたときはどうなるんだ？という疑問が出てくるのでやってみました。
前述の通り、CompanyとDepartmentは1対多、DepartmentとMemberも1対多です。
JOINの位置とwithDeletedの位置が関係しそうだったので、ケース分けしてみました。

#### すべてのJOIN前にwithDeletedメソッドを追加

とりあえずJOINする前に`withDeleted`メソッドを追加しました。

```TypeScript
const companies = await dataSource
  .getRepository(Company)
  .createQueryBuilder('company')
  .select('company.id', 'id')
  .withDeleted()
  .leftJoinAndSelect('company.departments', 'department')
  .leftJoinAndSelect('department.members', 'member')
  .getMany();
```

すべてのEntityが論理削除に関わらず取得されました。

|Company<br/>論理削除|Department<br/>論理削除|Member<br/>論理削除|Company<br/>取得|Department<br/>取得|Member<br/>取得|
|--|--|--|--|--|--|
|-|-|-|○|○|○|
|-|○|-|○|○|○|
|-|-|○|○|○|○|
|-|○|○|○|○|○|
|○|-|-|○|○|○|
|○|○|-|○|○|○|
|○|-|○|○|○|○|
|○|○|○|○|○|○|

（項目増えると図がわかりづらいですね）
とりあえずJOIN前に追加しておけばよさそうだというのがわかってきました。

#### DepartmentをJOINした後にwithDeletedメソッドを追加

順番に検証したかったので、次はDepartmentのJOIN後にしてみました。

```TypeScript
const companies = await dataSource
  .getRepository(Company)
  .createQueryBuilder('company')
  .select('company.id', 'id')
  .leftJoinAndSelect('company.departments', 'department')
  .withDeleted()
  .leftJoinAndSelect('department.members', 'member')
  .getMany();
```

ここまで来ると割と想像できそうですが、Departmentは論理削除されてるものが取得できませんでした。なので自動的に論理削除されたDepartmentに紐付いているMemberも取れてきませんでした。

|Company<br/>論理削除|Department<br/>論理削除|Member<br/>論理削除|Company<br/>取得|Department<br/>取得|Member<br/>取得|
|--|--|--|--|--|--|
|-|-|-|○|○|○|
|-|○|-|○|-|-|
|-|-|○|○|○|○|
|-|○|○|○|-|-|
|○|-|-|○|○|○|
|○|○|-|○|-|-|
|○|-|○|○|○|○|
|○|○|○|○|-|-|


#### MemberをJOINした後にwithDeletedメソッドを追加

最後はMemberのJOIN後にしてみました。

```TypeScript
const companies = await dataSource
  .getRepository(Company)
  .createQueryBuilder('company')
  .select('company.id', 'id')
  .leftJoinAndSelect('company.departments', 'department')
  .leftJoinAndSelect('department.members', 'member')
  .withDeleted()
  .getMany();
```

DepartmentもMemberも論理削除されてるものが取得できませんでした。

|Company<br/>論理削除|Department<br/>論理削除|Member<br/>論理削除|Company<br/>取得|Department<br/>取得|Member<br/>取得|
|--|--|--|--|--|--|
|-|-|-|○|○|○|
|-|○|-|○|-|-|
|-|-|○|○|○|-|
|-|○|○|○|-|-|
|○|-|-|○|○|○|
|○|○|-|○|-|-|
|○|-|○|○|○|-|
|○|○|○|○|-|-|

# 結論

どうやら`withDeleted`メソッドの後にJOINしたEntityは論理削除に関わらずデータを取得できるようだということが見えてきました。
逆にそれ以外のEntityは論理削除されたものが取得されないようです。

# 蛇足

前提で定義してたけど使っていなかったClientはDepartmentとかMemberとは直接関係のないリレーションなのですが、こういうEntityが複数ある場合JOINの順番をものすごく考慮しなければなりません。

「こっちのEntityは論理削除されたものも必要だけど、あっちのEntityはいらなくて、そっちは必要で…」
「論理削除されたものも必要な場合は`withDeleted`の後にJOINして、いらないものはその前にJOINして…」

これは実装するときも見直すときもレビューするのも辛いです。
なぜJOINする際のメソッド(`innerJoin`とか`leftJoin`とか)にオプション定義しなかったのかなぁと勝手なことを思ってしまいました。

一応、簡単にできる対応方法としてはすべてのJOIN前に`withDeleted`を追加して、論理削除されたものが必要ないEntityはJOIN時の条件に`deletedAt IS NULL`を追加する、のようにして対処できます。

混乱するのを避けるためにも、データの整理とか設計の見直しとかをしてなるべくそういう状況にならないようにしたいですね。