Drizzle 소프트 관계

Drizzle 관계의 유일한 목적은 여러분이 관계형 데이터를 가장 간단하고 명확하게 쿼리할 수 있도록 하는 것입니다.

관계형 쿼리
조인을 사용한 쿼리
import * as schema from './schema';
import { drizzle } from 'drizzle-orm/…';

const db = drizzle(client, { schema });

const result = db.query.users.findMany({
  with: {
    posts: true,
  },
});
[{
  id: 10,
  name: "Dan",
  posts: [
    {
      id: 1,
      content: "SQL은 정말 멋져요",
      authorId: 10,
    },
    {
      id: 2,
      content: "하지만 관계형 쿼리를 확인해보세요",
      authorId: 10,
    }
  ]
}]

일대일 관계

Drizzle ORM은 relations 연산자를 사용하여 테이블 간의 일대일 관계를 정의할 수 있는 API를 제공합니다.

다음은 사용자와 사용자 간의 일대일 관계 예제입니다. 한 사용자가 다른 사용자를 초대할 수 있는 경우를 보여줍니다. 이 예제는 자기 참조(self reference)를 사용합니다.

import { pgTable, serial, text, integer, boolean } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';

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

export const usersRelations = relations(users, ({ one }) => ({
    invitee: one(users, {
        fields: [users.invitedBy],
        references: [users.id],
    }),
}));

또 다른 예제로, 사용자의 프로필 정보를 별도의 테이블에 저장하는 경우를 살펴보겠습니다. 이 경우 외래 키가 profile_info 테이블에 저장되므로, user 관계에는 fieldsreferences가 없습니다. 이는 TypeScript에게 user.profileInfonull일 수 있음을 알려줍니다.

import { pgTable, serial, text, integer, jsonb } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';

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

export const usersRelations = relations(users, ({ one }) => ({
    profileInfo: one(profileInfo),
}));

export const profileInfo = pgTable('profile_info', {
    id: serial('id').primaryKey(),
    userId: integer('user_id').references(() => users.id),
    metadata: jsonb('metadata'),
});

export const profileInfoRelations = relations(profileInfo, ({ one }) => ({
    user: one(users, { fields: [profileInfo.userId], references: [users.id] }),
}));

const user = await queryUserWithProfileInfo();
//____^? 타입 { id: number, profileInfo: { ... } | null  }

이 예제에서 user.profileInfonull일 수 있음을 TypeScript가 인식합니다. 이는 외래 키가 profile_info 테이블에 있기 때문입니다.

일대다(One-to-many) 관계

Drizzle ORM은 relations 연산자를 사용하여 테이블 간의 일대다 관계를 정의할 수 있는 API를 제공합니다.

예를 들어, 사용자와 그들이 작성한 게시물 간의 일대다 관계를 정의해 보겠습니다.

import { pgTable, serial, text, integer } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';

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

export const usersRelations = relations(users, ({ many }) => ({
    posts: many(posts),
}));

export const posts = pgTable('posts', {
    id: serial('id').primaryKey(),
    content: text('content'),
    authorId: integer('author_id'),
});

export const postsRelations = relations(posts, ({ one }) => ({
    author: one(users, {
        fields: [posts.authorId],
        references: [users.id],
    }),
}));

이제 게시물에 댓글을 추가해 보겠습니다.

...

export const posts = pgTable('posts', {
    id: serial('id').primaryKey(),
    content: text('content'),
    authorId: integer('author_id'),
});

export const postsRelations = relations(posts, ({ one, many }) => ({
    author: one(users, {
        fields: [posts.authorId],
        references: [users.id],
    }),
    comments: many(comments)
}));

export const comments = pgTable('comments', {
    id: serial('id').primaryKey(),
    text: text('text'),
    authorId: integer('author_id'),
    postId: integer('post_id'),
});

export const commentsRelations = relations(comments, ({ one }) => ({
    post: one(posts, {
        fields: [comments.postId],
        references: [posts.id],
    }),
}));

이 코드는 사용자, 게시물, 댓글 간의 관계를 정의합니다. 사용자는 여러 게시물을 작성할 수 있고, 각 게시물은 여러 댓글을 가질 수 있습니다. 이러한 관계는 Drizzle ORM의 relations 연산자를 통해 쉽게 정의할 수 있습니다.

다대다 관계

Drizzle ORM은 junction 또는 join 테이블을 통해 테이블 간의 다대다 관계를 정의할 수 있는 API를 제공합니다. 이 테이블은 명시적으로 정의되어야 하며, 관련된 테이블 간의 연결을 저장합니다.

사용자와 그룹 간의 다대다 관계 예제:

import { relations } from 'drizzle-orm';
import { integer, pgTable, primaryKey, serial, text } from 'drizzle-orm/pg-core';

// 사용자 테이블 정의
export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  name: text('name'),
});

// 사용자와 그룹 간의 관계 정의
export const usersRelations = relations(users, ({ many }) => ({
  usersToGroups: many(usersToGroups),
}));

// 그룹 테이블 정의
export const groups = pgTable('groups', {
  id: serial('id').primaryKey(),
  name: text('name'),
});

// 그룹과 사용자 간의 관계 정의
export const groupsRelations = relations(groups, ({ many }) => ({
  usersToGroups: many(usersToGroups),
}));

// 사용자와 그룹을 연결하는 junction 테이블 정의
export const usersToGroups = pgTable(
  'users_to_groups',
  {
    userId: integer('user_id')
      .notNull()
      .references(() => users.id),
    groupId: integer('group_id')
      .notNull()
      .references(() => groups.id),
  },
  (t) => ({
    pk: primaryKey({ columns: [t.userId, t.groupId] }),
  }),
);

// junction 테이블과의 관계 정의
export const usersToGroupsRelations = relations(usersToGroups, ({ one }) => ({
  group: one(groups, {
    fields: [usersToGroups.groupId],
    references: [groups.id],
  }),
  user: one(users, {
    fields: [usersToGroups.userId],
    references: [users.id],
  }),
}));

이 예제에서는 usersgroups 테이블 간의 다대다 관계를 정의하고, 이를 연결하는 usersToGroups 테이블을 생성합니다. usersToGroups 테이블은 userIdgroupId를 외래 키로 사용하여 두 테이블 간의 관계를 관리합니다.

외래 키(Foreign Keys)

relations가 외래 키와 비슷해 보인다는 것을 눈치챘을 겁니다. 심지어 references 속성도 가지고 있죠. 그렇다면 둘의 차이는 무엇일까요?

외래 키도 비슷한 목적을 가지고 있지만, 테이블 간의 관계를 정의하는 방식에서 relations와는 다른 수준에서 동작합니다.

외래 키는 데이터베이스 수준의 제약 조건입니다. 모든 insert, update, delete 작업에서 검사되며, 제약 조건이 위반되면 오류를 발생시킵니다.
반면에 **relations**는 더 높은 수준의 추상화입니다. 애플리케이션 수준에서만 테이블 간의 관계를 정의하는 데 사용됩니다.
relations는 데이터베이스 스키마에 영향을 미치지 않으며, 암묵적으로 외래 키를 생성하지도 않습니다.

이것이 의미하는 바는, relations와 외래 키를 함께 사용할 수 있지만, 서로 의존하지 않는다는 것입니다.
외래 키를 사용하지 않고 relations를 정의할 수 있고(그 반대도 가능), 이는 외래 키를 지원하지 않는 데이터베이스에서도 사용할 수 있게 해줍니다.

다음 두 예제는 Drizzle의 관계형 쿼리를 사용하여 데이터를 조회할 때 동일하게 동작합니다.

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

export const usersRelations = relations(users, ({ one, many }) => ({
    profileInfo: one(users, {
        fields: [profileInfo.userId],
        references: [users.id],
    }),
}));

export const profileInfo = pgTable('profile_info', {
    id: serial('id').primaryKey(),
    userId: integer("user_id"),
    metadata: jsonb("metadata"),
});

외래 키(Foreign Key) 동작

더 자세한 정보는 PostgreSQL 외래 키 문서를 참고하세요.

부모 테이블의 참조된 데이터가 수정될 때 발생할 동작을 지정할 수 있습니다. 이러한 동작을 “외래 키 동작”이라고 합니다. PostgreSQL은 이러한 동작에 대해 여러 옵션을 제공합니다.

삭제/업데이트 시 동작

ON DELETE와 유사하게, 참조된 컬럼이 변경(업데이트)될 때 호출되는 ON UPDATE도 있습니다. 가능한 동작은 동일하지만, SET NULLSET DEFAULT의 경우 컬럼 목록을 지정할 수 없습니다. 이 경우, CASCADE는 참조된 컬럼의 업데이트된 값이 참조하는 행에 복사됨을 의미합니다.

Drizzle에서는 references()의 두 번째 인자를 사용하여 외래 키 동작을 추가할 수 있습니다.

동작 타입

export type UpdateDeleteAction = 'cascade' | 'restrict' | 'no action' | 'set null' | 'set default';

// references 인터페이스의 두 번째 인자
actions?: {
		onUpdate?: UpdateDeleteAction;
		onDelete?: UpdateDeleteAction;
	} | undefined

다음 예제에서 posts 스키마의 author 필드에 onDelete: 'cascade'를 추가하면, user를 삭제할 때 관련된 모든 Post 레코드도 함께 삭제됩니다.

import { pgTable, serial, text, integer } from 'drizzle-orm/pg-core';

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

export const posts = pgTable('posts', {
	id: serial('id').primaryKey(),
	name: text('name'),
	author: integer('author').references(() => users.id, {onDelete: 'cascade'}).notNull(),
});

foreignKey 연산자로 지정된 제약 조건의 경우, 외래 키 동작은 다음과 같은 구문으로 정의됩니다.

import { foreignKey, pgTable, serial, text, integer } from 'drizzle-orm/pg-core';

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

export const posts = pgTable('posts', {
	id: serial('id').primaryKey(),
	name: text('name'),
	author: integer('author').notNull(),
}, (table) => ({
		fk: foreignKey({
			name: "author_fk",
			columns: [table.author],
			foreignColumns: [users.id],
		})
			.onDelete('cascade')
			.onUpdate('cascade')
	}),
);

관계 명확히 하기

Drizzle은 동일한 두 테이블 간에 여러 관계를 정의할 때, 이를 명확히 구분하기 위해 relationName 옵션을 제공합니다. 예를 들어, authorreviewer 관계를 모두 갖는 posts 테이블을 정의하는 경우를 생각해볼 수 있습니다.

import { pgTable, serial, text, integer } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
 
export const users = pgTable('users', {
	id: serial('id').primaryKey(),
	name: text('name'),
});
 
export const usersRelations = relations(users, ({ many }) => ({
	author: many(posts, { relationName: 'author' }),
	reviewer: many(posts, { relationName: 'reviewer' }),
}));
 
export const posts = pgTable('posts', {
	id: serial('id').primaryKey(),
	content: text('content'),
	authorId: integer('author_id'),
	reviewerId: integer('reviewer_id'),
});
 
export const postsRelations = relations(posts, ({ one }) => ({
	author: one(users, {
		fields: [posts.authorId],
		references: [users.id],
		relationName: 'author',
	}),
	reviewer: one(users, {
		fields: [posts.reviewerId],
		references: [users.id],
		relationName: 'reviewer',
	}),
}));

위 예제에서 relationName을 사용하여 authorreviewer 관계를 명확히 구분하고 있습니다. 이를 통해 동일한 두 테이블 간에 여러 관계를 정의할 때 발생할 수 있는 혼란을 방지할 수 있습니다.