조인 [SQL]

SQL의 조인 절은 테이블 간의 관련 컬럼을 기반으로 2개 이상의 테이블을 결합하는 데 사용됩니다. Drizzle ORM의 조인 문법은 SQL 유사성과 타입 안정성 사이의 균형을 맞춥니다.

조인 타입

Drizzle ORM은 INNER JOIN [LATERAL], FULL JOIN, LEFT JOIN [LATERAL], RIGHT JOIN, CROSS JOIN [LATERAL]을 위한 API를 제공합니다. 아래 테이블 스키마를 기반으로 한 예제를 살펴보겠습니다:

export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  name: text('name').notNull(),
});

export const pets = pgTable('pets', {
  id: serial('id').primaryKey(),
  name: text('name').notNull(),
  ownerId: integer('owner_id').notNull().references(() => users.id),
})

Left Join (왼쪽 조인)

const result = await db.select().from(users).leftJoin(pets, eq(users.id, pets.ownerId))
select ... from "users" left join "pets" on "users"."id" = "pets"."owner_id"
// result type
const result: {
    user: {
        id: number;
        name: string;
    };
    pets: {
        id: number;
        name: string;
        ownerId: number;
    } | null;
}[];

Left Join Lateral (왼쪽 조인 래터럴)

const subquery = db.select().from(pets).where(gte(users.age, 16)).as('userPets')
const result = await db.select().from(users).leftJoinLateral(subquery, sql`true`)
select ... from "users" left join lateral (select ... from "pets" where "users"."age" >= 16) "userPets" on true
// result type
const result: {
    user: {
        id: number;
        name: string;
    };
    userPets: {
        id: number;
        name: string;
        ownerId: number;
    } | null;
}[];

Right Join (오른쪽 조인)

const result = await db.select().from(users).rightJoin(pets, eq(users.id, pets.ownerId))
select ... from "users" right join "pets" on "users"."id" = "pets"."owner_id"
// result type
const result: {
    user: {
        id: number;
        name: string;
    } | null;
    pets: {
        id: number;
        name: string;
        ownerId: number;
    };
}[];

Inner Join (내부 조인)

const result = await db.select().from(users).innerJoin(pets, eq(users.id, pets.ownerId))
select ... from "users" inner join "pets" on "users"."id" = "pets"."owner_id"
// result type
const result: {
    user: {
        id: number;
        name: string;
    };
    pets: {
        id: number;
        name: string;
        ownerId: number;
    };
}[];

Inner Join Lateral (내부 조인 래터럴)

const subquery = db.select().from(pets).where(gte(users.age, 16)).as('userPets')
const result = await db.select().from(users).innerJoinLateral(subquery, sql`true`)
select ... from "users" inner join lateral (select ... from "pets" where "users"."age" >= 16) "userPets" on true
// result type
const result: {
    user: {
        id: number;
        name: string;
    };
    userPets: {
        id: number;
        name: string;
        ownerId: number;
    };
}[];

Full Join (전체 조인)

const result = await db.select().from(users).fullJoin(pets, eq(users.id, pets.ownerId))
select ... from "users" full join "pets" on "users"."id" = "pets"."owner_id"
// result type
const result: {
    user: {
        id: number;
        name: string;
    } | null;
    pets: {
        id: number;
        name: string;
        ownerId: number;
    } | null;
}[];

Cross Join (교차 조인)

const result = await db.select().from(users).crossJoin(pets)
select ... from "users" cross join "pets"
// result type
const result: {
    user: {
        id: number;
        name: string;
    };
    pets: {
        id: number;
        name: string;
        ownerId: number;
    };
}[];

Cross Join Lateral (교차 조인 래터럴)

const subquery = db.select().from(pets).where(gte(users.age, 16)).as('userPets')
const result = await db.select().from(users).crossJoinLateral(subquery)
select ... from "users" cross join lateral (select ... from "pets" where "users"."age" >= 16) "userPets"
// result type
const result: {
    user: {
        id: number;
        name: string;
    };
    userPets: {
        id: number;
        name: string;
        ownerId: number;
    };
}[];

부분 선택

특정 필드의 하위 집합을 선택하거나 플랫한 응답 타입을 원한다면, Drizzle ORM은 부분 선택을 사용한 조인을 지원하며 .select({ ... }) 구조를 기반으로 반환 타입을 자동으로 추론합니다.

await db.select({
  userId: users.id,
  petId: pets.id,
}).from(user).leftJoin(pets, eq(users.id, pets.ownerId))
select "users"."id", "pets"."id" from "users" left join "pets" on "users"."id" = "pets"."owner_id"
// result type
const result: {
  userId: number;
  petId: number | null;
}[];

petId가 이제 null이 될 수 있다는 것을 눈치챘을 것입니다. 이는 왼쪽 조인을 사용하고 있어서 반려동물이 없는 사용자가 있을 수 있기 때문입니다.

부분 선택 필드와 집계를 위해 sql 연산자를 사용할 때 매우 중요한 점은, 적절한 결과 타입 추론을 위해 sql<type | null>을 사용해야 한다는 것입니다. 이것은 여러분이 직접 처리해야 합니다!

const result = await db.select({
  userId: users.id,
  petId: pets.id,
  petName1: sql`upper(${pets.name})`,
  petName2: sql<string | null>`upper(${pets.name})`,
  //˄해당 필드를 왼쪽 조인하고 있으므로, 타입에 'string | null'을 명시적으로 지정해야 합니다
}).from(user).leftJoin(pets, eq(users.id, pets.ownerId))
select "users"."id", "pets"."id", upper("pets"."name")... from "users" left join "pets" on "users"."id" = "pets"."owner_id"
// result type
const result: {
  userId: number;
  petId: number | null;
  petName1: unknown;
  petName2: string | null;
}[];

많은 컬럼이 있는 테이블을 조인할 때 수많은 nullable 필드를 피하기 위해 중첩된 선택 객체 문법을 활용할 수 있습니다. 스마트 타입 추론은 모든 테이블 필드를 nullable로 만드는 대신 전체 객체를 nullable로 만듭니다!

await db.select({
  userId: users.id,
  userName: users.name,
  pet: {
    id: pets.id,
    name: pets.name,
    upperName: sql<string>`upper(${pets.name})`
  }
}).from(user).fullJoin(pets, eq(users.id, pets.ownerId))
select ... from "users" full join "pets" on "users"."id" = "pets"."owner_id"
// result type
const result: {
    userId: number | null;
    userName: string | null;
    pet: {
        id: number;
        name: string;
        upperName: string;
    } | null;
}[];

별칭 & 셀프 조인

Drizzle ORM은 테이블 별칭을 지원하며, 셀프 조인을 수행해야 할 때 매우 유용합니다.

사용자와 그들의 부모를 함께 조회해야 한다고 가정해봅시다:

index.ts
schema.ts
import { user } from "./schema";

const parent = alias(user, "parent");
const result = db
  .select()
  .from(user)
  .leftJoin(parent, eq(parent.id, user.parentId));
select ... from "user" left join "user" "parent" on "parent"."id" = "user"."parent_id"
// result type
const result: {
    user: {
        id: number;
        name: string;
        parentId: number;
    };
    parent: {
        id: number;
        name: string;
        parentId: number;
    } | null;
}[];

결과 집계

Drizzle ORM은 드라이버에서 이름이 매핑된 결과를 구조를 변경하지 않고 전달합니다.

원하는 방식으로 결과를 자유롭게 처리할 수 있으며, 다음은 다대일 관계 데이터를 매핑하는 예제입니다:

type User = typeof users.$inferSelect;
type Pet = typeof pets.$inferSelect;

const rows = db.select({
    user: users,
    pet: pets,
  }).from(users).leftJoin(pets, eq(users.id, pets.ownerId)).all();

const result = rows.reduce<Record<number, { user: User; pets: Pet[] }>>(
  (acc, row) => {
    const user = row.user;
    const pet = row.pet;

    if (!acc[user.id]) {
      acc[user.id] = { user, pets: [] };
    }

    if (pet) {
      acc[user.id].pets.push(pet);
    }

    return acc;
  },
  {}
);

// result type
const result: Record<number, {
    user: User;
    pets: Pet[];
}>;

다대일 예제

import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
import { drizzle } from 'drizzle-orm/better-sqlite3';

const cities = sqliteTable('cities', {
  id: integer('id').primaryKey(),
  name: text('name'),
});

const users = sqliteTable('users', {
  id: integer('id').primaryKey(),
  name: text('name'),
  cityId: integer('city_id').references(() => cities.id)
});

const db = drizzle();

const result = db.select().from(cities).leftJoin(users, eq(cities.id, users.cityId)).all();

다대다 예제

const users = sqliteTable('users', {
  id: integer('id').primaryKey(),
  name: text('name'),
});

const chatGroups = sqliteTable('chat_groups', {
  id: integer('id').primaryKey(),
  name: text('name'),
});

const usersToChatGroups = sqliteTable('usersToChatGroups', {
  userId: integer('user_id').notNull().references(() => users.id),
  groupId: integer('group_id').notNull().references(() => chatGroups.id),
});


// id가 1인 사용자 그룹과 모든 참가자(사용자) 조회
db.select()
  .from(usersToChatGroups)
  .leftJoin(users, eq(usersToChatGroups.userId, users.id))
  .leftJoin(chatGroups, eq(usersToChatGroups.groupId, chatGroups.id))
  .where(eq(chatGroups.id, 1))
  .all();