BaseModelClass๋ ๋ชจ๋ Model์ด ์์๋ฐ๋ ๊ธฐ๋ณธ ํด๋์ค๋ก, ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ ๊ทผ, ์ฟผ๋ฆฌ ์คํ, ํธ๋์ญ์
๊ด๋ฆฌ ๋ฑ์ ํต์ฌ ๊ธฐ๋ฅ์ ์ ๊ณตํฉ๋๋ค.
๋ฐ์ดํฐ๋ฒ ์ด์ค ์ ๊ทผ ๋ฉ์๋
getDB(preset)
Knex ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ฐ๊ฒฐ์ ๋ฐํํฉ๋๋ค.
getDB(preset: DBPreset): Knex
ํ๋ผ๋ฏธํฐ:
preset: "r" (์ฝ๊ธฐ) ๋๋ "w" (์ฐ๊ธฐ)
์ฌ์ฉ ์์:
async findCustomQuery(): Promise<User[]> {
const rdb = this.getDB("r");
return rdb("users")
.select("*")
.where("is_active", true);
}
DBPreset ์ข
๋ฅ:
"r": Read - ์ฝ๊ธฐ ์ ์ฉ (SELECT)
"w": Write - ์ฐ๊ธฐ ๊ฐ๋ฅ (INSERT, UPDATE, DELETE)
์ฝ๊ธฐ/์ฐ๊ธฐ๋ฅผ ๋ถ๋ฆฌํ๋ฉด ๋ฐ์ดํฐ๋ฒ ์ด์ค ๋ฆฌํ๋ฆฌ์ผ์ด์
์์ ์ฝ๊ธฐ ๋ถํ๋ฅผ ๋ถ์ฐํ ์ ์์ต๋๋ค.
getPuri(preset)
Puri ์ฟผ๋ฆฌ ๋น๋๋ฅผ ๋ฐํํฉ๋๋ค. ํธ๋์ญ์
์ด ํ์ฑํ๋ ๊ฒฝ์ฐ ํธ๋์ญ์
์ฐ๊ฒฐ์ ์๋์ผ๋ก ์ฌ์ฉํฉ๋๋ค.
getPuri(preset: DBPreset): PuriWrapper
ํ๋ผ๋ฏธํฐ:
preset: "r" (์ฝ๊ธฐ) ๋๋ "w" (์ฐ๊ธฐ)
์ฌ์ฉ ์์:
async findActive(): Promise<User[]> {
const rdb = this.getPuri("r");
return rdb
.table("users")
.where("is_active", true)
.orderBy("created_at", "desc")
.many();
}
getPuri()๋ ํธ๋์ญ์
์ปจํ
์คํธ๋ฅผ ์๋์ผ๋ก ๊ฐ์งํฉ๋๋ค. @transactional() ๋ฐ์ฝ๋ ์ดํฐ ๋ด์์ ํธ์ถํ๋ฉด ํธ๋์ญ์
์ฐ๊ฒฐ์ ๋ฐํํฉ๋๋ค.
Subset ์ฟผ๋ฆฌ ๋ฉ์๋
getSubsetQueries(subset)
ํน์ Subset์ ๋ํ ์ฟผ๋ฆฌ ๋น๋๋ฅผ ๋ฐํํฉ๋๋ค.
getSubsetQueries<T extends TSubsetKey>(
subset: T
): {
qb: Puri;
onSubset: <S>(subset: S) => Puri;
}
๋ฐํ๊ฐ:
qb: ์ฟผ๋ฆฌ ๋น๋ (์กฐ๊ฑด ์ถ๊ฐ์ฉ)
onSubset: Subset๋ณ ํ์
์บ์คํ
ํจ์
์ฌ์ฉ ์์:
async findMany<T extends UserSubsetKey>(
subset: T,
params: UserListParams
): Promise<ListResult<UserSubsetMapping[T]>> {
const { qb } = this.getSubsetQueries(subset);
// ์กฐ๊ฑด ์ถ๊ฐ
if (params.keyword) {
qb.whereLike("users.email", `%${params.keyword}%`);
}
// ์คํ
return this.executeSubsetQuery({ subset, qb, params });
}
onSubset()์ ํ์
์ฒดํฌ์ฉ์
๋๋ค. ์ค์ ๋ก๋ ๊ฐ์ qb ๊ฐ์ฒด๋ฅผ ๋ฐํํ๋ฏ๋ก, ์ฑ๋ฅ์ ์ํฅ์ ์ฃผ์ง ์์ต๋๋ค.// ์ด ๋ ์ฝ๋๋ ๋์ผํฉ๋๋ค
qb.where("users.id", 1);
onSubset("A").where("users.id", 1);
executeSubsetQuery(params)
Subset ์ฟผ๋ฆฌ๋ฅผ ์คํํ๊ณ ๊ฒฐ๊ณผ๋ฅผ ๋ฐํํฉ๋๋ค. ํ์ด์ง๋ค์ด์
, Loader, Hydration, Enhancer๋ฅผ ์๋์ผ๋ก ์ฒ๋ฆฌํฉ๋๋ค.
executeSubsetQuery<T extends TSubsetKey>(
params: {
subset: T;
qb: Puri;
params: {
num: number;
page: number;
queryMode?: "list" | "count" | "both";
};
enhancers?: EnhancerMap;
debug?: boolean;
optimizeCountQuery?: boolean;
}
): Promise<ListResult<TSubsetMapping[T]>>
ํ๋ผ๋ฏธํฐ:
| ํ๋ผ๋ฏธํฐ | ํ์
| ์ค๋ช
| ๊ธฐ๋ณธ๊ฐ |
|---|
subset | string | Subset ํค | ํ์ |
qb | Puri | ์ฟผ๋ฆฌ ๋น๋ | ํ์ |
params.num | number | ํ์ด์ง ํฌ๊ธฐ | ํ์ |
params.page | number | ํ์ด์ง ๋ฒํธ (1๋ถํฐ ์์) | ํ์ |
params.queryMode | string | ์ฟผ๋ฆฌ ๋ชจ๋ | "both" |
enhancers | object | Virtual ํ๋ ๊ณ์ฐ ํจ์ | - |
debug | boolean | ์ฟผ๋ฆฌ ๋๋ฒ๊น
์ถ๋ ฅ | false |
optimizeCountQuery | boolean | COUNT ์ฟผ๋ฆฌ ์ต์ ํ | false |
queryMode ์ต์
:
| ๋ชจ๋ | ๋ฐํ๊ฐ | ์ฌ์ฉ ์์ |
|---|
"both" | { rows, total } | ์ผ๋ฐ ๋ชฉ๋ก ์กฐํ (๊ธฐ๋ณธ๊ฐ) |
"list" | { rows } | total ๋ถํ์ํ ๊ฒฝ์ฐ |
"count" | { total } | ๊ฐ์๋ง ํ์ํ ๊ฒฝ์ฐ |
์ฌ์ฉ ์์:
async findMany<T extends UserSubsetKey>(
subset: T,
params: UserListParams
): Promise<ListResult<UserSubsetMapping[T]>> {
const { qb } = this.getSubsetQueries(subset);
if (params.keyword) {
qb.whereLike("users.email", `%${params.keyword}%`);
}
const enhancers = this.createEnhancers({
A: (row) => row,
SS: (row) => row,
});
return this.executeSubsetQuery({
subset,
qb,
params: {
num: params.num ?? 24,
page: params.page ?? 1,
},
enhancers,
});
}
์คํ ์์:
- COUNT ์ฟผ๋ฆฌ ์คํ (total ๊ณ์ฐ)
- LIST ์ฟผ๋ฆฌ ์คํ (ํ์ด์ง๋ค์ด์
์ ์ฉ)
- Loader ์คํ (HasMany, ManyToMany ๋ฐ์ดํฐ ๋ก๋ฉ)
- Hydrate (flat ๊ฐ์ฒด โ ์ค์ฒฉ ๊ฐ์ฒด ๋ณํ)
- Enhancer ์ ์ฉ (virtual ํ๋ ๊ณ์ฐ)
- Internal ํ๋ ์ ๊ฑฐ
createEnhancers(enhancers)
Enhancer ๊ฐ์ฒด๋ฅผ ์์ฑํ๋ ํฌํผ ํจ์์
๋๋ค. ํ์
๊ฒ์ฆ๊ณผ ์ถ๋ก ์ ์ ๊ณตํฉ๋๋ค.
createEnhancers<T extends TSubsetKey>(
enhancers: EnhancerMap<T>
): EnhancerMap<T>
Enhancer๋?
Enhancer๋ ์ฟผ๋ฆฌ ๊ฒฐ๊ณผ์ ๊ฐ์ ํ๋๋ฅผ ์ถ๊ฐํ๊ฑฐ๋ ๋ฐ์ดํฐ๋ฅผ ๋ณํํ๋ ํจ์์
๋๋ค.
์ฌ์ฉ ์์:
const enhancers = this.createEnhancers({
A: (row) => ({
...row,
full_name: `${row.first_name} ${row.last_name}`,
age: calculateAge(row.birth_date),
}),
SS: (row) => row, // ๋ณํ ์์
});
await this.executeSubsetQuery({
subset: "A",
qb,
params,
enhancers,
});
Enhancer๋ ๊ฐ row๋ง๋ค ํธ์ถ๋๋ฏ๋ก, ๋ฌด๊ฑฐ์ด ์์
์ ํผํ๋ ๊ฒ์ด ์ข์ต๋๋ค. ํ์ํ๋ค๋ฉด Loader๋ฅผ ์ฌ์ฉํ๊ฑฐ๋ ๋ณ๋ API๋ก ๋ถ๋ฆฌํ์ธ์.
์ ํธ๋ฆฌํฐ ๋ฉ์๋
getInsertedIds(wdb, rows, tableName, unqKeyFields, chunkSize)
์ฝ์
๋ ๋ ์ฝ๋์ ID๋ฅผ ์กฐํํฉ๋๋ค. Unique ํค ๊ธฐ๋ฐ์ผ๋ก ์กฐํํ๋ฏ๋ก upsert ํ์ ์ ์ฉํฉ๋๋ค.
getInsertedIds(
wdb: Knex,
rows: Record<string, unknown>[],
tableName: string,
unqKeyFields: string[],
chunkSize?: number
): Promise<number[]>
ํ๋ผ๋ฏธํฐ:
| ํ๋ผ๋ฏธํฐ | ํ์
| ์ค๋ช
| ๊ธฐ๋ณธ๊ฐ |
|---|
wdb | Knex | ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ฐ๊ฒฐ | - |
rows | object[] | ์ฝ์
๋ ๋ ์ฝ๋ | - |
tableName | string | ํ
์ด๋ธ๋ช
| - |
unqKeyFields | string[] | Unique ํค ํ๋๋ช
| - |
chunkSize | number | ํ๋ฒ์ ์กฐํํ ๊ฐ์ | 500 |
์ฌ์ฉ ์์:
async saveUsers(users: User[]): Promise<number[]> {
const wdb = this.getDB("w");
// ์ด๋ฉ์ผ๋ก upsert
await wdb("users")
.insert(users)
.onConflict("email")
.merge();
// ์ด๋ฉ์ผ๋ก ID ์กฐํ
const ids = await this.getInsertedIds(
wdb,
users,
"users",
["email"]
);
return ids;
}
hydrate(rows)
Flat ๋ ์ฝ๋๋ฅผ ์ค์ฒฉ ๊ฐ์ฒด๋ก ๋ณํํฉ๋๋ค. JOIN ๊ฒฐ๊ณผ์ table__field ํ์์ ๊ฐ์ฒด๋ก ๋ณํํฉ๋๋ค.
hydrate<T>(rows: T[]): T[]
๋ณํ ๊ท์น:
user__name โ { user: { name } }
user__profile__bio โ { user: { profile: { bio } } }
- nullable relation์ id๊ฐ null์ด๋ฉด ๊ฐ์ฒด ์ ์ฒด๋ฅผ null๋ก
์ฌ์ฉ ์์:
// Flat ๋ฐ์ดํฐ
const flatRows = [
{
id: 1,
name: "John",
user__id: 10,
user__email: "[email protected]",
user__profile__bio: "Hello",
}
];
// ์ค์ฒฉ ๊ฐ์ฒด๋ก ๋ณํ
const nested = this.hydrate(flatRows);
// [
// {
// id: 1,
// name: "John",
// user: {
// id: 10,
// email: "[email protected]",
// profile: {
// bio: "Hello"
// }
// }
// }
// ]
hydrate()๋ executeSubsetQuery() ๋ด๋ถ์์ ์๋์ผ๋ก ํธ์ถ๋๋ฏ๋ก, ์ง์ ํธ์ถํ ์ผ์ ๊ฑฐ์ ์์ต๋๋ค.
omitInternalFields(row, fields)
Internal ํ๋๋ฅผ ๊ฐ์ฒด์์ ์ ๊ฑฐํฉ๋๋ค. ์ค์ฒฉ ํ๋์ ๋ฐฐ์ด๋ ์ฒ๋ฆฌํฉ๋๋ค.
omitInternalFields<T extends object>(
row: T,
fields: string[]
): T
์ฌ์ฉ ์์:
const row = {
id: 1,
email: "[email protected]",
password: "hashed_password",
employee: {
id: 10,
salary: 50000,
department: { name: "IT" }
}
};
// Internal ํ๋ ์ ๊ฑฐ
const cleaned = this.omitInternalFields(row, [
"password",
"employee.salary"
]);
// {
// id: 1,
// email: "[email protected]",
// employee: {
// id: 10,
// department: { name: "IT" }
// }
// }
Subset์ internal ์ต์
์ผ๋ก ์ง์ ๋ ํ๋๋ executeSubsetQuery()์์ ์๋์ผ๋ก ์ ๊ฑฐ๋ฉ๋๋ค.{
"subsets": {
"A": [
"id",
"email",
{ "field": "password", "internal": true },
{ "field": "employee.salary", "internal": true }
]
}
}
destroy()
๋ฐ์ดํฐ๋ฒ ์ด์ค ์ฐ๊ฒฐ์ ์ข
๋ฃํฉ๋๋ค. ์ผ๋ฐ์ ์ผ๋ก ์ ํ๋ฆฌ์ผ์ด์
์ข
๋ฃ ์ ํธ์ถํฉ๋๋ค.
async destroy(): Promise<void>
์ฌ์ฉ ์์:
// ์ ํ๋ฆฌ์ผ์ด์
์ข
๋ฃ ์
process.on("SIGTERM", async () => {
await UserModel.destroy();
process.exit(0);
});
destroy()๋ฅผ ํธ์ถํ๋ฉด ๋ชจ๋ Model์ DB ์ฐ๊ฒฐ์ด ์ข
๋ฃ๋ฉ๋๋ค. ํ
์คํธ ํ๊ฒฝ์์๋ง ์ฌ์ฉํ์ธ์.
์ค์ ์ฌ์ฉ ํจํด
ํ์ค findMany ํจํด
async findMany<T extends UserSubsetKey, LP extends UserListParams>(
subset: T,
rawParams?: LP
): Promise<ListResult<LP, UserSubsetMapping[T]>> {
// 1. ๊ธฐ๋ณธ๊ฐ ์ค์
const params = {
num: 24,
page: 1,
search: "id" as const,
orderBy: "id-desc" as const,
...rawParams,
};
// 2. Subset ์ฟผ๋ฆฌ ํ๋
const { qb, onSubset } = this.getSubsetQueries(subset);
// 3. ํํฐ๋ง ์กฐ๊ฑด ์ถ๊ฐ
if (params.id) {
qb.whereIn("users.id", asArray(params.id));
}
if (params.keyword && params.search) {
if (params.search === "email") {
qb.whereLike("users.email", `%${params.keyword}%`);
} else if (params.search === "username") {
qb.whereLike("users.username", `%${params.keyword}%`);
}
}
// 4. ์ ๋ ฌ
if (params.orderBy === "id-desc") {
qb.orderBy("users.id", "desc");
} else if (params.orderBy === "created_at-desc") {
qb.orderBy("users.created_at", "desc");
}
// 5. Enhancer ์ ์
const enhancers = this.createEnhancers({
A: (row) => ({
...row,
full_name: `${row.first_name} ${row.last_name}`,
}),
SS: (row) => row,
});
// 6. ์ฟผ๋ฆฌ ์คํ
return this.executeSubsetQuery({
subset,
qb,
params,
enhancers,
});
}
๋ณต์กํ ํํฐ๋ง ํจํด
async findMany<T extends UserSubsetKey>(
subset: T,
params: UserListParams
): Promise<ListResult<UserSubsetMapping[T]>> {
const { qb } = this.getSubsetQueries(subset);
// ๋ ์ง ๋ฒ์ ํํฐ
if (params.created_after || params.created_before) {
if (params.created_after) {
qb.where("users.created_at", ">=", params.created_after);
}
if (params.created_before) {
qb.where("users.created_at", "<=", params.created_before);
}
}
// ๋ค์ค ๊ฐ ํํฐ
if (params.roles && params.roles.length > 0) {
qb.whereIn("users.role", params.roles);
}
// OR ์กฐ๊ฑด
if (params.keyword) {
qb.where((builder) => {
builder
.whereLike("users.email", `%${params.keyword}%`)
.orWhereLike("users.username", `%${params.keyword}%`)
.orWhereLike("users.phone", `%${params.keyword}%`);
});
}
// ๋ณต์กํ ์กฐ๊ฑด
if (params.is_premium_or_admin) {
qb.where((builder) => {
builder
.where("users.role", "admin")
.orWhere("users.subscription_level", "premium");
});
}
return this.executeSubsetQuery({
subset,
qb,
params: { num: params.num ?? 24, page: params.page ?? 1 },
});
}
์ปค์คํ
์ง๊ณ ์ฟผ๋ฆฌ
async getStatistics(): Promise<UserStatistics> {
const rdb = this.getDB("r");
const [stats] = await rdb("users")
.select([
rdb.raw("COUNT(*) as total_users"),
rdb.raw("COUNT(CASE WHEN is_active THEN 1 END) as active_users"),
rdb.raw("COUNT(CASE WHEN role = 'admin' THEN 1 END) as admin_count"),
rdb.raw("AVG(age) as average_age"),
])
.first();
return {
total_users: Number(stats.total_users),
active_users: Number(stats.active_users),
admin_count: Number(stats.admin_count),
average_age: Number(stats.average_age),
};
}
๋ค์ ๋จ๊ณ