はじめに
TypeORMで GROUP BY を含むサブクエリを .andWhereExists() に渡したとき、実行SQLから GROUP BY が消えてしまうという問題に遭遇しました。
HAVING と組み合わせた集計条件が含まれている場合、クエリの結果が誤った値になるため、同じ症状に困っている人向けに原因と解決した方法を共有します。
テーブル構成
例えば、以下のようなサブスクリプションサービスのテーブルがあるとします。
-- subscription_history テーブル
CREATE TABLE subscription_history (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
started_on DATE NOT NULL, -- 契約開始日
status_cd INTEGER NOT NULL -- 1: 有効 / 2: 解約済み
);
やりたいこと:「有効な契約期間が1つでも残っているユーザーを取得する」
ただし started_on が同じ契約をグループとして扱い、
そのグループ内に「解約済み(status_cd = 2)」が1件もなければ「有効」と判断します。
TypeORMのコード(問題のある書き方)
まずサブクエリを作る関数と、それを使う呼び出し元のコードです。
function getActiveSubscriptionSubQuery(
repo: Repository<SubscriptionHistory>,
userAlias: string,
): SelectQueryBuilder<SubscriptionHistory> {
return repo
.createQueryBuilder('sub')
.select('1')
.where(`sub.user_id = ${userAlias}.id`)
.andWhere('sub.deleted_at IS NULL')
.groupBy('sub.user_id')
.addGroupBy('sub.started_on') // ← started_on でグループ化
.having('COUNT(*) >= 1')
.andHaving(
'COUNT(CASE WHEN sub.status_cd = :statusCd THEN 1 END) = 0',
{ statusCd: 2 },
);
}
// --- 呼び出し元 ---
async function hasActiveSubscription(userId: number): Promise<boolean> {
const subQb = getActiveSubscriptionSubQuery(this.repoSubscription, 'user');
return !!await this.repoUser
.createQueryBuilder('user')
.select('user.id')
.where('user.id = :userId', { userId })
.andWhereExists(subQb) // ← ここが問題
.getRawOne();
}
サブクエリ単体をログ出力すると、GROUP BY が正しく含まれています。
SELECT 1
FROM subscription_history sub
WHERE sub.user_id = user.id
AND sub.deleted_at IS NULL
GROUP BY sub.user_id, sub.started_on
HAVING COUNT(*) >= 1
AND COUNT(CASE WHEN sub.status_cd = $1 THEN 1 END) = 0
しかし .andWhereExists(subQb) を使った実際の実行SQLをログで確認すると
SELECT "user"."id"
FROM "user" "user"
WHERE ( "user"."id" = $1 )
AND EXISTS (
SELECT 1
FROM "subscription_history" "sub"
WHERE ( "sub"."user_id" = "user"."id" )
AND ( "sub"."deleted_at" IS NULL )
HAVING COUNT(*) >= 1
AND COUNT(CASE WHEN "sub"."status_cd" = $2 THEN 1 END) = 0
)
GROUP BY がないまま HAVING だけが残っています。
GROUP BYが消えると何が変わるのか
例えば以下のデータがあるとします。
| id | user_id | started_on | status_cd |
|---|---|---|---|
| 1 | 5 | 2024-04-01 | 2(解約済み) |
| 2 | 5 | 2025-10-01 | 1(有効) |
user_id = 5 は、以前解約したが 2025年10月に再契約して現在も有効 なユーザーです。
GROUP BYあり(正しい動き)
◆ グループ A:started_on = 2024-04-01
解約済み(status_cd = 2)が1件 → HAVINGの条件を満たさない → 除外
◆ グループ B:started_on = 2025-10-01
解約済みが0件 → HAVINGの条件を満たす → 通過
→ 1つでも通過するグループがあるので EXISTS = true
→ user_id = 5 は「有効な契約あり」と判定される
GROUP BYなし(バグ時の動き)
◆ 全レコードを1グループとして扱う
id=1: status_cd = 2(解約済み)
id=2: status_cd = 1(有効)
→ 解約済みが1件あるのでHAVINGの条件を満たさない → 除外
→ user_id = 5 は「有効な契約なし」と判定される
原因
.andWhereExists() は渡された SelectQueryBuilder を内部で分解して再組み立てします。
その際に GROUP BY 句が欠落するという挙動があります。
(おそらくTypeORMのバグ)
解決策
getQuery() + setParameters() で自分でEXISTSに埋め込む
async function hasActiveSubscription(userId: number): Promise<boolean> {
const subQb = getActiveSubscriptionSubQuery(this.repoSubscription, 'user');
return !!await this.repoUser
.createQueryBuilder('user')
.select('user.id')
.where('user.id = :userId', { userId })
.andWhere(`EXISTS (${subQb.getQuery()})`)
.setParameters(subQb.getParameters())
.getRawOne();
}
※ .setParameters() を忘れないこと
subQb.getQuery() はSQL文字列のみを返します。
サブクエリ内のパラメータ(:statusCd など)は別管理されているため、
.setParameters(subQb.getParameters()) で親のQueryBuilderに引き継がないとエラーになります。
まとめ
以上の方法で無事GROUP BYを含めてSQLを実行できました。
andWhereExists()は便利なメソッドですが、内部でQueryBuilderを分解・再組み立てするため、GROUP BYが消えるという落とし穴があります。複雑なサブクエリを扱う際は、getQuery() でSQLをログ出力して意図通りになっているか確認するのがおすすめです。
同じ症状で詰まっている方の参考になれば幸いです。