Skip to content

Query Builder — 집계 & 서브쿼리 ​

GROUP BY, HAVING, 서브쿼리, DISTINCT는 find()가 표현력을 잃기 시작하는 지점입니다. 쿼리 빌더가 힘을 발휘하는 구간이죠. 이 페이지는 그 전부를 MySQL / PostgreSQL / SQLite에서 똑같이 동작하는 예제로 풀어봅니다.

GROUP BY와 집계 ​

카테고리별 게시글 수를 세고, 게시글이 5개 이상인 카테고리만 남긴다고 해 봅시다. 요구 하나에 GROUP BY와 집계 함수와 HAVING이 전부 들어와요. 여기서 qAlias()가 특히 빛을 발합니다 — 같은 집계 표현식을 SELECT와 HAVING에 한 번씩만 적으면 되거든요.

typescript
import { qAlias } from "@stingerloom/orm";

const p = qAlias(Post, "p");
const count = p.id.count();                // 한 번 정의

const stats = await em
  .createQueryBuilder(Post, "p")
  .select(["category"])
  .addSelect(count.as("postCount"))         // SELECT에 투영
  .groupBy(["p.category"])
  .having(count.gte(5))                     // HAVING에 재사용
  .addOrderBy(count.desc())                 // 정렬에도 재사용
  .getRawMany();
// [{ category: "tech", postCount: 42 }, { category: "life", postCount: 17 }, ...]

같은 쿼리를 raw sql 템플릿으로 쓴다면 어떨까요? COUNT(*)를 세 번 적게 됩니다.

typescript
import sql from "sql-template-tag";

await em.createQueryBuilder(Post, "p")
  .select(["category"])
  .addSelect(sql`COUNT(*)`, "postCount")
  .groupBy(["p.category"])
  .having(sql`COUNT(*) >= ${5}`)            // ← 반복
  .appendSql(sql`ORDER BY "postCount" DESC`)
  .getRawMany();

addOrderBy("postCount", "DESC")는 키를 FROM 별칭으로 한정해서 "p"."postCount"가 됩니다. SELECT 리스트에만 존재하는 별칭이라 DB가 거부하죠. SELECT 별칭으로 정렬하려면 appendSql(sql\ORDER BY "alias" ...`)`로 한 단계 내려갑니다 — 별칭이 그대로 살아남습니다.

둘 다 지원하긴 합니다. 다만 동일한 집계를 여러 자리에서 쓴다면 qAlias() 쪽이 유리해요. 기준을 바꿀 때 한 군데만 고치면 되니까요. .count(), .sum(), .avg(), .min(), .max(), .countDistinct() 전부 같은 방식으로 쓰고, 전체 연산자는 QueryDSL 집계에 정리돼 있습니다.

그룹 수 세기 ​

그룹화된 빌더에서 getCount()는 HAVING을 통과한 그룹의 개수를 돌려줍니다. 어느 한 그룹의 크기가 아니에요. paginate()와 getManyAndCount()의 total이 그룹 행과 맞아떨어지는 것도 이 규칙 덕분입니다. 그룹별 크기는 위처럼 집계를 투영한 뒤 getRawMany()로 읽으세요. 자세한 내용은 GROUP BY가 있는 getCount()를 참고하세요.

표현식으로 그룹화하기 ​

groupBy()의 문자열 항목은 모양에 따라 컬럼 참조 아니면 표현식으로 해석됩니다. prop이나 alias.prop 형태의 맨 이름은 컬럼 참조예요. NamingStrategy와 @Column({ name })을 거쳐 실제 컬럼명으로 바뀌고 별칭과 함께 인용됩니다. 함수 호출, 연산자, 공백처럼 SQL 문법이 섞여 있으면 표현식으로 보고 그대로 내보냅니다.

typescript
const byCategory = await em
  .createQueryBuilder(Post, "p")
  .selectRaw(["UPPER(category) AS category", "COUNT(*) AS postCount"])
  .groupBy(["UPPER(category)"])
  .addOrderBy("UPPER(category)", "ASC")
  .getRawMany();
// [{ category: "LIFE", postCount: 17 }, { category: "TECH", postCount: 42 }, ...]

이 규칙은 빌더의 문자열 자리 전부에 똑같이 적용됩니다 — selectRaw(), addSelect(), groupBy(), addOrderBy(), 그리고 윈도우 partitionBy()까지. 같은 토큰은 어디에 두든 같은 SQL이 되죠. "그대로 내보낸다"에는 두 가지 함의가 있습니다.

  • 표현식 문자열은 DB 컬럼명을 씁니다. 위의 category는 컬럼명이지 속성명이 아니에요. 표현식 안쪽은 아무것도 매핑하거나 인용하지 않고, 방언에도 묶입니다. 속성명을 인식하면서 방언까지 넘나드는 형태가 필요하면 QueryDSL을 쓰세요. p.category.toUpperCase(), Expressions.dateTrunc(p.createdAt, "month")처럼 속성을 해석하고, 파라미터를 바인딩하고, 방언별로 렌더링합니다.

    typescript
    const p = qAlias(Post, "p");
    const category = p.category.toUpperCase();
    
    await em
      .createQueryBuilder(Post, "p")
      .select([category.as("category"), p.id.count().as("postCount")])
      .groupBy([category])
      .addOrderBy(category, "ASC")
      .getRawMany();
  • 표현식 문자열은 raw SQL입니다. 코드에 리터럴로 적으세요. 요청 입력으로 조립하면 안 됩니다. 값은 바인딩 파라미터로 넘깁니다: groupBy([sql\ROUND(price / ${100})`])`.

맨 이름은 언제나 컬럼 참조입니다. selectRaw(["... AS month"]) 뒤에 groupBy(["month"])를 쓰면 "p"."month"가 되어 DB가 거부해요. SELECT 별칭으로 그룹화하려면 sql 조각을 넘기세요 — groupBy([sql\month`]). 위 addOrderBy("postCount", ...)설명과 같은 이치입니다. 빈 문자열 항목은 SQL을 만들기 전에INVALID_QUERY`로 거부됩니다.

SELECT에서 집계 함수 바로 쓰기 ​

addSelect()에 sql 템플릿 리터럴을 넘기면 집계 컬럼을 추가할 수 있습니다. 두 번째 인자 별칭이 결과 객체의 키가 돼요.

typescript
import sql from "sql-template-tag";

const stats = await em
  .createQueryBuilder(Order, "o")
  .addSelect(sql`AVG("o"."price")`, "avgPrice")
  .addSelect(sql`SUM("o"."price")`, "totalRevenue")
  .addSelect(sql`MIN("o"."price")`, "cheapest")
  .addSelect(sql`MAX("o"."price")`, "mostExpensive")
  .addSelect(sql`COUNT(*)`, "orderCount")
  .getRawMany();
// [{ avgPrice: 42.5, totalRevenue: 850, cheapest: 10, mostExpensive: 99, orderCount: 20 }]

그룹화된 엔티티 컬럼과 집계 컬럼을 함께 쓸 수도 있습니다.

typescript
const salesByCategory = await em
  .createQueryBuilder(Product, "p")
  .select(["category"])
  .addSelect(sql`AVG("p"."price")`, "avgPrice")
  .addSelect(sql`COUNT(*)`, "productCount")
  .groupBy(["category"])
  .having(sql`AVG("p"."price") > ${50}`)
  .orderBy({ category: "ASC" })
  .getRawMany();
// [{ category: "electronics", avgPrice: 299.99, productCount: 15 }, ...]

엔티티 인식 조인까지 얹으면 테이블을 넘나드는 집계도 자연스럽게 됩니다.

typescript
const p = qAlias(Post, "p");
const u = qAlias(User, "u");
const postCount = p.id.count();
const avgLikes = p.likeCount.avg();

const authorStats = await em
  .createQueryBuilder(Post, "p")
  .leftJoin(User, "u", (join) => join.on(p.col("authorId"), "=", u.col("id")))
  .selectRaw([u.col("name")])
  .addSelect(postCount.as("postCount"))
  .addSelect(avgLikes.as("avgLikes"))
  .groupBy([u.col("name")])
  .having(postCount.gte(3))
  .addOrderBy(postCount.desc())
  .getRawMany();
// [{ name: "Alice", postCount: 12, avgLikes: 45.3 }, ...]

표현식 빌더 addSelect() ​

addSelect()는 콜백도 받습니다. 콜백은 @ComputedColumn({ expression })과 동일한, 다이얼렉트 이식성이 보장되는 e 컨텍스트를 받아요 — e.col("alias.prop"), e.iff(...), e.count() / e.sum() / e.avg() / e.min() / e.max(), 그리고 ScalarExpression의 전체 산술 체인(add / sub / mul / div / floor / …)까지. raw SQL 없이도 어느 다이얼렉트에서든 올바른 SQL이 렌더링됩니다.

typescript
// COUNT(node.name) - 1 AS depth — raw SQL 없는 중첩 집합 깊이 계산
qb.addSelect((e) => e.count("node.name").sub(1), "depth");

// (c.rgt - (c.lft + 1)) / 2 AS children
qb.addSelect(
  (e) => e.col("c.rgt").sub(e.col("c.lft").add(1)).div(2).floor(),
  "children",
);

스칼라 결과는 별칭 인자가 필수입니다 — addSelect((e) => ..., "alias") — 없으면 INVALID_QUERY를 던져요. 집계는 별칭 인자를 쓰거나 .as("alias")로 마무리하면 됩니다.

이 조합이 가능한 건 AggregateExpression이 산술을 직접 지원하게 됐기 때문입니다. .toScalar()가 집계를 ScalarExpression으로 이어 주고, .add() / .sub() / .mul() / .div()는 .toScalar().add(...)의 단축형이에요. 같은 표면이 qAlias() 집계에도 열려 있으니 p.id.count().sub(1)도 그대로 동작합니다.

서브쿼리 ​

쿼리 빌더는 WHERE, SELECT, FROM 어느 자리에든 서브쿼리를 넣을 수 있습니다. find()로는 불가능한 영역이지만, 빌더에서는 간단해요.

WHERE IN 서브쿼리 ​

타입드 단축은 whereInSubquery(column, subBuilder)입니다 — 패턴 & 생산성에서 사용 예제를 확인하세요. 서브쿼리가 SelectQueryBuilder로 표현되지 않을 때(예: raw 소스에서 이미 Sql 조각을 받은 경우)에만 Conditions.inSubquery()로 내려갑니다.

typescript
import sql from "sql-template-tag";
import { Conditions } from "@stingerloom/orm";

// 한국 작성자의 게시글 찾기
const subquery = em
  .createQueryBuilder(User, "u")
  .select(["id"])
  .where("country", "KR")
  .toSql();

const posts = await em
  .createQueryBuilder(Post, "p")
  .where(Conditions.inSubquery(`"p"."authorId"`, sql`(${subquery})`))
  .getMany();

WHERE EXISTS / NOT EXISTS ​

상관 서브쿼리에는 Conditions.exists() 또는 Conditions.notExists()를 씁니다.

typescript
// 발행된 게시글이 한 편이라도 있는 작성자 찾기
const authors = await em
  .createQueryBuilder(User, "a")
  .where(Conditions.exists(sql`(SELECT 1 FROM "post" "p" WHERE "p"."author_id" = "a"."id" AND "p"."status" = ${"published"})`))
  .getMany();

SELECT 절의 스칼라 서브쿼리 ​

addSelect()로 계산 컬럼처럼 스칼라 서브쿼리를 얹을 수 있습니다.

typescript
const authors = await em
  .createQueryBuilder(User, "u")
  .select(["id", "name"])
  .addSelect(
    sql`(SELECT COUNT(*) FROM "post" "p" WHERE "p"."author_id" = "u"."id")`,
    "postCount",
  )
  .getRawMany();
// [{ id: 1, name: "Alice", postCount: 5 }, { id: 2, name: "Bob", postCount: 0 }]

FROM 서브쿼리 (파생 테이블) ​

asSubquery()로 SelectQueryBuilder를 파생 테이블로 바꾸고, RawQueryBuilder에서 이 파생 테이블을 쿼리합니다.

typescript
// 1단계: 안쪽 쿼리
const inner = em
  .createQueryBuilder(Post, "p")
  .select(["authorId"])
  .addSelect(sql`COUNT(*)`, "cnt")
  .groupBy(["authorId"]);

// 2단계: 파생 테이블로 사용
const qb = em.createQueryBuilder();
const results = await em.query(
  qb
    .select(['"sub"."authorId"', '"sub"."cnt"'])
    .from(inner.asSubquery("sub"))
    .where([sql`"sub"."cnt" >= ${3}`])
    .build()
);
// 게시글 3편 이상인 작성자

CTE (공통 테이블 표현식) ​

복잡한 다단계 쿼리에는 RawQueryBuilder의 CTE를 씁니다.

typescript
const qb = em.createQueryBuilder();

const results = await em.query(
  qb
    .with("active_authors", (sub) =>
      sub
        .select(['DISTINCT "authorId"'])
        .from('"post"')
        .where([sql`"status" = ${"published"}`])
    )
    .select(['"u"."id"', '"u"."name"'])
    .from('"user" "u"')
    .where([sql`"u"."id" IN (SELECT "authorId" FROM "active_authors")`])
    .build()
);

재귀 CTE — 댓글 스레드나 조직도 같은 계층 데이터 — 가 필요하면 Raw SQL & CTE를 참고하세요.

SelectQueryBuilder를 안는 상위 헬퍼 ​

서브쿼리 자체가 SelectQueryBuilder라면 타입드 헬퍼 쪽이 편합니다. 파라미터 바인딩이 그대로 유지되고, 안쪽 쿼리에도 자동완성이 돼요.

  • whereInSubquery(column, subQb) / whereNotInSubquery(...)
  • whereExistsSubquery(subQb) / whereNotExistsSubquery(...)
  • addSelectSubquery(subQb, alias)

전체 세트는 편의 패턴에서 다룹니다.

스칼라 집계 터미널 ​

쿼리의 WHERE / JOIN 범위 안에서 단일 집계 값이 필요할 때, getSum(), getAvg(), getMin(), getMax() 터미널 메서드를 쓰면 addSelect(sql\SUM(...)`, "s").getRawOne()` 블록을 직접 작성하지 않아도 됩니다. 빌더의 FROM / JOIN / WHERE / soft-delete / 테넌트 범위를 그대로 재사용하며, 이미 설정된 LIMIT / OFFSET / ORDER BY는 무시합니다.

typescript
const qb = em
  .createQueryBuilder(Order, "o")
  .leftJoin(User, "u", (j) => j.on("o.userId", "=", "u.id"))
  .where("u.country", "KR")
  .where("o.status", "completed");

const total   = await qb.getSum("amount");   // SUM(o.amount)
const average = await qb.getAvg("amount");   // AVG(o.amount)
const lowest  = await qb.getMin("amount");   // MIN(o.amount)
const highest = await qb.getMax("amount");   // MAX(o.amount)

네 메서드 모두 Promise<number>를 반환합니다. 매칭 행이 없거나 해당 컬럼이 모두 NULL인 경우 결과는 0으로 변환됩니다. EntityManager.sum() / avg() / min() / max()와 동일한 동작이에요.

column 인자는 루트 엔티티의 camelCase 프로퍼티 이름으로 제한된 ColumnOf<T> 타입이라 오타는 컴파일 에러로 잡힙니다.

메서드SQL반환
getSum(column)SELECT SUM(col) FROM ...Promise<number> — 빈 결과 시 0
getAvg(column)SELECT AVG(col) FROM ...Promise<number> — 빈 결과 시 0
getMin(column)SELECT MIN(col) FROM ...Promise<number> — 빈 결과 시 0
getMax(column)SELECT MAX(col) FROM ...Promise<number> — 빈 결과 시 0

추가 GROUP BY 없이 동일 범위에서 여러 집계 스칼라가 필요할 때 getCount()와 함께 자연스럽게 조합할 수 있습니다.

DISTINCT ​

중복 없는 행만 받고 싶을 때는 DISTINCT를 활성화합니다.

typescript
const uniqueCities = await em
  .createQueryBuilder(User, "u")
  .select(["city"])
  .setDistinct()
  .getPartialMany();
// SELECT DISTINCT "u"."city" FROM "user" AS "u"

결과 집합에서 중복 행을 제거합니다. 선택한 컬럼의 조합이 겹치는 행이 많을 때 유용해요.

집계 내부 DISTINCT — COUNT(DISTINCT col) — 가 필요하면 .count() 대신 .countDistinct()를 씁니다. 두 DISTINCT는 의미가 다릅니다. 행 수준 DISTINCT는 결과 행 수 자체를 줄이고, 집계 내부 DISTINCT는 집계 대상 값의 중복만 제거합니다.

DISTINCT ON (PostgreSQL 전용)은 Raw SQL & CTE에서 다룹니다.

GROUP BY로는 부족할 때 — 윈도우 집계 ​

GROUP BY는 그룹마다 행을 하나로 눌러 버립니다. "개별 행은 그대로 두면서 팀별 누적 합계를 같이 보여달라"처럼 원본 행을 유지한 채 집계만 얹어야 하는 경우에는 GROUP BY가 답이 되지 못해요. 이럴 때 등장하는 게 윈도우 함수입니다.

typescript
const e = qAlias(Event, "e");

qb.select(["id", "teamId", "score"])
  .addSelect(
    e.score.sum().over()
      .partitionBy(e.teamId)
      .orderBy(e.createdAt.asc())
      .rowsBetween("UNBOUNDED PRECEDING", "CURRENT ROW")
      .as("running_total"),
  )
  .getRawMany();

표현식을 .toScalar()로 마무리하면 WHERE / HAVING 안에 서브쿼리처럼 꽂을 수도 있습니다. API 전체와 리더보드 실전 예제는 QueryDSL → 윈도우 함수에서 이어서 볼 수 있어요.

다음 단계 ​

Released under the MIT License.