Kysely?
Kysely 는 Typescript를 위한 Query Builder다. 프로그래머가 정의한 모델을 읽고 Query가 맞는지, 틀린지를 컴파일 에러로 가르쳐준다. Kysely로 간단한 Select에서 복잡한 CTE ( Common Table Expression )까지 모두 다룰 수 있다.
이 글에서는 Alleys를 개발하면서 Kysely를 활용한 방법에 대해 소개하겠다.
지원되는 DB
불필요하게 글을 읽는 상황을 방지하기 위해 지원되는 DB부터 살펴보자. Kysely는 RDBMS 또는 Relation이 있는 NoSQL들을 지원한다 .
Database Structure 생성하기
먼저 Query가 맞다 틀리다를 구별할 기준(Source of Truth)이 필요하다. ORM에서는 Entity이라 부르고, Kysely에서는 Database Structure라고 부른다.
// https://kysely.dev/docs/getting-started#types
import {
ColumnType, // Select, Insert, Update에 대해 각각 다른 타입을 지정할 때 쓰는 Util type
Generated, // DB에서 값을 생성하는 column을 표시
} from 'kysely'
export interface User {
id: Generated<number>
first_name: string
last_name: string | null
gender: 'man' | 'woman' | 'other'
created_at: ColumnType<Date, string | undefined, never>
}
💡
Kysely에서는 undefined 타입 사용을 금지한다. undefined 타입은 테이블의 컬럼을 선택하는데 쓰이기 때문이다.
그런데 이미 소제목에서 스포일러를 했다. 굳이 코딩할 필요 없이 Kysely가 Data structure를 생성하는 방법 이 있다.
Third-party library | Supported DB |
---|---|
kysely-codegen | Kysely에서 지원하는 드라이버가 있으면 전부 가능 |
prisma-kysely | Prisma 파일에서 타입 생성 |
kanel-kysely | Kanel이라고 하는 PostgreSQL 타입 생성기를 이용 |
kysely-schema-generator | MySQL에 직접 접근 |
나는 kysely-codegen
이 제일 사용하기 편해서 이것을 쓰고 있다.
사용1. Client
Client 만드는 방법. 처음에 제대로 library를 만들어야 나중에 다시 찾을 일이 없다.
import { Kysely, PostgresDialect } from "kysely"
import pg from "pg"
import type { DB } from "./db" // Kysely가 생성한 Database Structure 타입
export function getKysely(connStr: string) {
return new Kysely<DB>({
dialect: (
new PostgresDialect({
pool: new pg.Pool({
connectionString: connStr,
}),
})
),
})
}
Dialect
는 Kysely와 DB 사이의 인터페이스다. Postgres 외의 DB는 여기를 참고.
사용2. Query
기본적인 사용 방법은 공식 가이드 에 잘 나와있다.
const persons = await db
.selectFrom('person')
.select('id')
.where('first_name', '=', 'Arnold')
.execute()
SELECT
"id"
FROM
"person"
WHERE
"first_name" = $1
대충 이런 식이다. 조심해야 할 부분은, execute()
를 호출하지 않으면 아무 일도 일어나지 않는다는 점이다.
사용3. Conflict
Alleys를 만들 때 정말 자주 썼던 패턴이다.
import { sql } from "kysely"
try {
user = await kysely
.insertInto("user")
.values({
id: sql`uuid_generate_v4()`,
unique_name,
email,
email_verified: true,
password_hash,
created_at: now,
updated_at: now,
})
.onConflict(oc => oc.column("unique_name").doNothing())
.returning("id")
.executeTakeFirst()
if (!user) return c.body("unique name conflict", 409)
} catch (e) {
console.error("db user insert error", e)
return c.body("internal", 500)
}
onConflict()
를 통해 conflict를 어떻게 대처할지 정할 수 있다. 이 함수가 없으면 일반적인 에러와 conflict를 구분하기 어렵기 때문이다.
만약 conflict가 두 가지 이상의 컬럼에서 발생한다면 에러 처리를 어떻게 할까?
.onConflict(oc => oc
.columns(["user_id", "exhibition_id"])
.doNothing()
)
두 컬럼을 구분할 필요가 없다면 이렇게 간단하게 해결할 수 있다.
만약 conflict가 발생한 컬럼을 특정해야한다면? 그런 끔찍한 상황은 상상하기 싫지만, onConflict()
함수를 포기하고 직접 에러 처리를 해야 할 것이다.
(Conflict 에러 예시)
✘ [ERROR] db user insert error error: duplicate key value violates unique constraint "user_unique_name_key"
at Object.parseErrorMessage
(file:///home/humbledragon/Documents/GitHub/alleys-mono-v2/node_modules/@neondatabase/serverless/index.js:1591:6)
at Object.handlePacket
(file:///home/humbledragon/Documents/GitHub/alleys-mono-v2/node_modules/@neondatabase/serverless/index.js:1552:48)
at Object.parse
(file:///home/humbledragon/Documents/GitHub/alleys-mono-v2/node_modules/@neondatabase/serverless/index.js:1536:63)
at yt.Di
(file:///home/humbledragon/Documents/GitHub/alleys-mono-v2/node_modules/@neondatabase/serverless/index.js:1598:74)
at yt.emit
(file:///home/humbledragon/Documents/GitHub/alleys-mono-v2/node_modules/@neondatabase/serverless/index.js:400:63)
at null.<anonymous>
(file:///home/humbledragon/Documents/GitHub/alleys-mono-v2/node_modules/@neondatabase/serverless/index.js:928:19)
{
length: 213,
severity: 'ERROR',
code: '23505',
detail: 'Key (unique_name)=(muffin) already exists.',
hint: undefined,
position: undefined,
internalPosition: undefined,
internalQuery: undefined,
where: undefined,
schema: 'public',
table: 'user',
column: undefined,
dataType: undefined,
constraint: 'user_unique_name_key',
file: 'nbtinsert.c',
line: '666',
routine: '_bt_check_unique'
}
사용4. Dynamic where
단순한 Where 사용은 여기를 참고.
만약 적용해야하는 Where 조건이 API 파라미터에 따라 있기도 하고, 없기도 하다면? Kysely가 아주 잘 해결해줄 수 있다.
try {
let q = kysely
.selectFrom("exhibition as e")
.select(eb => eb.fn.countAll<number>("e").as("total"))
if (req.location_ids) {
q = q.where("e.location_id", "in", req.location_ids)
}
if (req.price_min) {
q = q.where("e.price", ">=", req.price_min)
}
if (req.price_max) {
q = q.where("e.price", "<=", req.price_max)
}
// ...
// Execute
data = await q.executeTakeFirst()
if (!data) {
console.error("db select exhibition total error: result was empty")
return c.text("internal", 500)
}
} catch (e) {
console.error("db select exhibition error:", e)
return c.text("internal", 500)
}
이렇게 where()
함수를 여러번 호출하면 Kysely가 알아서 and
로 엮어준다. ( 출처 )
사용5. NarrowType
가끔 컬럼 타입이 예상과 다른 상황이 생긴다.
.selectFrom("review as r")
.leftJoin("user as u", "r.user_id", "u.id")
.leftJoin("exhibition as e", "r.exhibition_id", "e.id")
.select([
// ...
"u.unique_name",
])
.$narrowType<{ user_id: string, unique_name: string }>()
Review는 User에 Foreign key를 가지고 있기 때문에 반드시 Join이 성공해야한다. 하지만 Kysely는 Relation을 모르기 때문에 Join이 실패할 수도 있다고 생각한다. 이런 특수한 경우 .$narrowType<>()
함수를 통해 타입을 억지로 교정할 수 있다.
마치며
이정도면 Kysely가 얼마나 유용한 라이브러리인지 알 수 있을 것이다.
그런데 생각해보면 Kysely의 강력함은 Typescript의 타입 시스템에 의해 가능한 것이다. 검색해보니 이정도로 강력한 타입 시스템을 가진 언어는 Scala, Haskell, Idris, Kotlin, Rust, Swift, F#... 생각보다 많네 ?
아무튼 타입은 프로그래머에게 있어서 소중한 기능이다.