๋ฉ”์ธ ์ฝ˜ํ…์ธ ๋กœ ๊ฑด๋„ˆ๋›ฐ๊ธฐ
pnpm scaffold ๋ช…๋ น์–ด๋Š” Entity ์ •์˜๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ๋ณด์ผ๋Ÿฌํ”Œ๋ ˆ์ดํŠธ ์ฝ”๋“œ๋ฅผ ์ž๋™ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. Model ํด๋ž˜์Šค์™€ ํ…Œ์ŠคํŠธ ํŒŒ์ผ์„ ๋ช‡ ์ดˆ ๋งŒ์— ์ƒ์„ฑํ•˜์—ฌ ๊ฐœ๋ฐœ ์‹œ๊ฐ„์„ ํฌ๊ฒŒ ๋‹จ์ถ•ํ•ฉ๋‹ˆ๋‹ค.

๊ธฐ๋ณธ ๊ฐœ๋…

Scaffold๋Š” Entity๋ฅผ ์ฝ”๋“œ๋กœ ๋ณ€ํ™˜ํ•˜๋Š” ์ž๋™ํ™” ๋„๊ตฌ์ž…๋‹ˆ๋‹ค:
  • Entity ๊ธฐ๋ฐ˜: Entity ์ •์˜์—์„œ ํƒ€์ž…๊ณผ ๊ตฌ์กฐ ํŒŒ์•…
  • ํƒ€์ž… ์•ˆ์ „: TypeScript ํƒ€์ž…์ด ์ž๋™์œผ๋กœ ์ƒ์„ฑ๋จ
  • ์ผ๊ด€์„ฑ: ๋ชจ๋“  ์ฝ”๋“œ๊ฐ€ ๊ฐ™์€ ํŒจํ„ด์„ ๋”ฐ๋ฆ„
  • ์ˆ˜์ • ๊ฐ€๋Šฅ: ์ƒ์„ฑ ํ›„ ์ž์œ ๋กญ๊ฒŒ ์ปค์Šคํ„ฐ๋งˆ์ด์ง•

๋ช…๋ น์–ด

model - Model ํด๋ž˜์Šค ์ƒ์„ฑ

Entity๋ฅผ ์œ„ํ•œ Model ํด๋ž˜์Šค๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.
pnpm scaffold model
๋Œ€ํ™”ํ˜• ํ”„๋กฌํ”„ํŠธ๊ฐ€ ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค:
? Please select entity: (Use arrow keys)
โฏ User
  Post
  Comment
์ƒ์„ฑ๋˜๋Š” ํŒŒ์ผ:
๐Ÿ“src/models/
๐Ÿ“„TSUser.model.ts - User Model ํด๋ž˜์Šค
์ƒ์„ฑ๋œ ์ฝ”๋“œ:
src/models/User.model.ts
import { BaseModelClass } from "sonamu";
import type { InferSelectModel } from "sonamu";
import { UserEntity } from "../entities/User.entity";
import type { UserSubsetKey, UserSubsetMapping } from "../sonamu.generated";
import { userLoaderQueries, userSubsetQueries } from "../sonamu.generated.sso";

export type User = InferSelectModel<typeof UserEntity>;

class UserModelClass extends BaseModelClass<
  UserSubsetKey,
  UserSubsetMapping,
  typeof userSubsetQueries,
  typeof userLoaderQueries
> {
  constructor() {
    super("User", userSubsetQueries, userLoaderQueries);
  }
  
  // TODO: Model ๋ฉ”์„œ๋“œ ์ถ”๊ฐ€
}

export const UserModel = new UserModelClass();

model_test - ํ…Œ์ŠคํŠธ ํŒŒ์ผ ์ƒ์„ฑ

Model์„ ์œ„ํ•œ ํ…Œ์ŠคํŠธ ํŒŒ์ผ์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.
pnpm scaffold model_test
์ƒ์„ฑ๋˜๋Š” ํŒŒ์ผ:
๐Ÿ“src/models/
๐Ÿ“„TSUser.model.test.ts - User Model ํ…Œ์ŠคํŠธ
์ƒ์„ฑ๋œ ์ฝ”๋“œ:
src/models/User.model.test.ts
import { beforeAll, describe, test } from "bun:test";
import { expect } from "@jest/globals";
import { FixtureManager } from "sonamu/test";
import { UserModel } from "./User.model";

describe("User Model", () => {
  beforeAll(async () => {
    await FixtureManager.sync();
  });

  test("findById", async () => {
    const user = await UserModel.findById(1);
    expect(user).toBeDefined();
    expect(user?.id).toBe(1);
  });

  test("findMany", async () => {
    const users = await UserModel.findMany({
      num: 10,
      page: 1,
    });
    expect(users.rows.length).toBeGreaterThan(0);
  });

  // TODO: ์ถ”๊ฐ€ ํ…Œ์ŠคํŠธ ์ž‘์„ฑ
});

View ์Šค์บํด๋”ฉ (๊ฐœ๋ฐœ ์ค‘)

View ์ปดํฌ๋„ŒํŠธ ์ž๋™ ์ƒ์„ฑ ๊ธฐ๋Šฅ์€ ํ˜„์žฌ ๊ฐœ๋ฐœ ์ค‘์ž…๋‹ˆ๋‹ค. Sonamu UI๋ฅผ ํ†ตํ•ด React ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
# Sonamu UI ์‹คํ–‰
pnpm sonamu ui

# Scaffolding ํƒญ์—์„œ View ์ƒ์„ฑ

์ƒ์„ฑ๋œ ์ฝ”๋“œ ๊ตฌ์กฐ

Model ํด๋ž˜์Šค

์ƒ์„ฑ๋œ Model ํด๋ž˜์Šค๋Š” ๋‹ค์Œ ๊ตฌ์กฐ๋ฅผ ๊ฐ€์ง‘๋‹ˆ๋‹ค:
class UserModelClass extends BaseModelClass<
  UserSubsetKey,
  UserSubsetMapping,
  typeof userSubsetQueries,
  typeof userLoaderQueries
> {
  constructor() {
    super("User", userSubsetQueries, userLoaderQueries);
  }
  
  // Entity ํƒ€์ž…
  entity = UserEntity;
  
  // ๋ฉ”์„œ๋“œ ์ถ”๊ฐ€ ์œ„์น˜
  async findByEmail(email: string): Promise<User | null> {
    return this.getPuri("r")
      .where("email", email)
      .first();
  }
}
์ž๋™ ์ƒ์„ฑ๋˜๋Š” ๊ธฐ๋Šฅ:
  • โœ… TypeScript ํƒ€์ž…
  • โœ… Entity ์—ฐ๊ฒฐ
  • โœ… BaseModel ์ƒ์†
  • โœ… ๊ธฐ๋ณธ CRUD ๋ฉ”์„œ๋“œ
์ง์ ‘ ์ถ”๊ฐ€ํ•ด์•ผ ํ•˜๋Š” ๊ฒƒ:
  • ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง ๋ฉ”์„œ๋“œ
  • ๋ณต์žกํ•œ ์ฟผ๋ฆฌ
  • ๋ฐ์ดํ„ฐ ๊ฒ€์ฆ
  • ๊ด€๊ณ„ ๋กœ๋”ฉ

ํ…Œ์ŠคํŠธ ํŒŒ์ผ

์ƒ์„ฑ๋œ ํ…Œ์ŠคํŠธ ํŒŒ์ผ์€ ๊ธฐ๋ณธ ํ…Œ์ŠคํŠธ๋ฅผ ํฌํ•จํ•ฉ๋‹ˆ๋‹ค:
describe("User Model", () => {
  beforeAll(async () => {
    await FixtureManager.sync();  // Fixture ๋กœ๋“œ
  });

  test("findById", async () => {
    // ๊ธฐ๋ณธ CRUD ํ…Œ์ŠคํŠธ
  });

  test("findMany", async () => {
    // ๋ชฉ๋ก ์กฐํšŒ ํ…Œ์ŠคํŠธ
  });
});
ํ™•์žฅ ๊ฐ€๋Šฅ:
  • ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง ํ…Œ์ŠคํŠธ ์ถ”๊ฐ€
  • Edge case ํ…Œ์ŠคํŠธ
  • ์„ฑ๋Šฅ ํ…Œ์ŠคํŠธ
  • ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ

๊ฐœ๋ฐœ ์›Œํฌํ”Œ๋กœ์šฐ

1. Entity ์ •์˜

src/entities/Product.entity.ts
export const ProductEntity = {
  properties: [
    { name: "id", type: "int", primaryKey: true, autoIncrement: true },
    { name: "title", type: "string", length: 255 },
    { name: "price", type: "decimal", precision: 10, scale: 2 },
    { name: "category_id", type: "int" },
    { name: "created_at", type: "datetime" },
  ],
  belongsTo: [
    { entityId: "Category", as: "category" },
  ],
} satisfies EntityType;

2. ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜

# ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์ƒ์„ฑ ๋ฐ ์ ์šฉ
pnpm migrate run

3. Model ์ƒ์„ฑ

# Model ํด๋ž˜์Šค ์ƒ์„ฑ
pnpm scaffold model

# Entity: Product ์„ ํƒ

4. ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง ์ถ”๊ฐ€

src/models/Product.model.ts
class ProductModelClass extends BaseModelClass<
  ProductSubsetKey,
  ProductSubsetMapping,
  typeof productSubsetQueries,
  typeof productLoaderQueries
> {
  constructor() {
    super("Product", productSubsetQueries, productLoaderQueries);
  }
  
  // ์นดํ…Œ๊ณ ๋ฆฌ๋ณ„ ์ƒํ’ˆ ์กฐํšŒ
  async findByCategory(categoryId: number) {
    return this.getPuri("r")
      .where("category_id", categoryId)
      .where("deleted_at", null);
  }
  
  // ๊ฐ€๊ฒฉ๋Œ€๋ณ„ ์ƒํ’ˆ ์กฐํšŒ
  async findByPriceRange(min: number, max: number) {
    return this.getPuri("r")
      .whereBetween("price", [min, max]);
  }
}

5. ํ…Œ์ŠคํŠธ ์ƒ์„ฑ

# ํ…Œ์ŠคํŠธ ํŒŒ์ผ ์ƒ์„ฑ
pnpm scaffold model_test

# Entity: Product ์„ ํƒ

6. ํ…Œ์ŠคํŠธ ์ž‘์„ฑ

src/models/Product.model.test.ts
describe("Product Model", () => {
  beforeAll(async () => {
    await FixtureManager.sync();
  });

  test("findByCategory", async () => {
    const products = await ProductModel.findByCategory(1);
    expect(products.length).toBeGreaterThan(0);
    expect(products[0].category_id).toBe(1);
  });

  test("findByPriceRange", async () => {
    const products = await ProductModel.findByPriceRange(10000, 50000);
    products.forEach(product => {
      expect(product.price).toBeGreaterThanOrEqual(10000);
      expect(product.price).toBeLessThanOrEqual(50000);
    });
  });
});

7. API ์ถ”๊ฐ€

src/models/Product.model.ts
class ProductModelClass extends BaseModelClass {
  @api({ httpMethod: "GET" })
  async findByCategory(categoryId: number) {
    const { qb } = this.getSubsetQueries("A");
    qb.where("category_id", categoryId);
    
    return this.executeSubsetQuery({
      subset: "A",
      qb,
      params: { num: 20, page: 1 },
    });
  }
}

์ปค์Šคํ„ฐ๋งˆ์ด์ง•

Model ๋ฉ”์„œ๋“œ ์ถ”๊ฐ€

์ƒ์„ฑ๋œ Model์— ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์„ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:
class UserModelClass extends BaseModelClass<
  UserSubsetKey,
  UserSubsetMapping,
  typeof userSubsetQueries,
  typeof userLoaderQueries
> {
  constructor() {
    super("User", userSubsetQueries, userLoaderQueries);
  }

  // ์ด๋ฉ”์ผ๋กœ ์‚ฌ์šฉ์ž ์ฐพ๊ธฐ
  async findByEmail(email: string): Promise<User | null> {
    return this.getPuri("r")
      .where("email", email)
      .first();
  }
  
  // ํ™œ์„ฑ ์‚ฌ์šฉ์ž๋งŒ ์กฐํšŒ
  async findActiveUsers() {
    return this.getPuri("r")
      .where("is_active", true)
      .where("deleted_at", null);
  }
  
  // ์‚ฌ์šฉ์ž ํ†ต๊ณ„
  async getUserStats(userId: number) {
    const [stats] = await this.getPuri("r")
      .select({
        postCount: this.raw("COUNT(posts.id)"),
        commentCount: this.raw("COUNT(comments.id)"),
      })
      .leftJoin("posts", "posts.user_id", "users.id")
      .leftJoin("comments", "comments.user_id", "users.id")
      .where("users.id", userId)
      .groupBy("users.id");
    
    return stats;
  }
}

ํ…Œ์ŠคํŠธ ํ™•์žฅ

๋” ๋งŽ์€ ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค:
describe("User Model - Extended", () => {
  test("findByEmail - ์กด์žฌํ•˜๋Š” ์ด๋ฉ”์ผ", async () => {
    const user = await UserModel.findByEmail("[email protected]");
    expect(user).toBeDefined();
  });

  test("findByEmail - ์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ด๋ฉ”์ผ", async () => {
    const user = await UserModel.findByEmail("[email protected]");
    expect(user).toBeNull();
  });

  test("findActiveUsers", async () => {
    const users = await UserModel.findActiveUsers();
    users.forEach(user => {
      expect(user.is_active).toBe(true);
      expect(user.deleted_at).toBeNull();
    });
  });

  test("getUserStats", async () => {
    const stats = await UserModel.getUserStats(1);
    expect(stats.postCount).toBeGreaterThanOrEqual(0);
    expect(stats.commentCount).toBeGreaterThanOrEqual(0);
  });
});

์—ฌ๋Ÿฌ Entity ์ผ๊ด„ ์ƒ์„ฑ

์Šคํฌ๋ฆฝํŠธ ํ™œ์šฉ

# ๋ชจ๋“  Entity์— ๋Œ€ํ•ด Model ์ƒ์„ฑ
for entity in User Post Comment Category; do
  echo $entity | pnpm scaffold model
done

ํ…Œ์ŠคํŠธ ์ผ๊ด„ ์ƒ์„ฑ

# ๋ชจ๋“  Model์— ๋Œ€ํ•ด ํ…Œ์ŠคํŠธ ์ƒ์„ฑ
for entity in User Post Comment Category; do
  echo $entity | pnpm scaffold model_test
done

์‹ค์ „ ํŒ

1. Entity ๋จผ์ € ์™„์„ฑํ•˜๊ธฐ

// โœ… Entity๋ฅผ ์™„์ „ํžˆ ์ •์˜ํ•œ ํ›„ scaffold
export const UserEntity = {
  properties: [
    // ๋ชจ๋“  ํ•„๋“œ ์ •์˜
  ],
  indexes: [
    // ์ธ๋ฑ์Šค ์ •์˜
  ],
  belongsTo: [
    // ๊ด€๊ณ„ ์ •์˜
  ],
} satisfies EntityType;

2. ์ ์ง„์  ํ™•์žฅ

// 1. ๊ธฐ๋ณธ Model ์ƒ์„ฑ
pnpm scaffold model

// 2. ๊ฐ„๋‹จํ•œ ๋ฉ”์„œ๋“œ๋ถ€ํ„ฐ ์ถ”๊ฐ€
async findById(id: number) { ... }

// 3. ๋ณต์žกํ•œ ๋กœ์ง ์ถ”๊ฐ€
async complexQuery() { ... }

// 4. ํ…Œ์ŠคํŠธ ์ž‘์„ฑ
pnpm scaffold model_test

3. ์ปจ๋ฒค์…˜ ์ค€์ˆ˜

// โœ… ์ผ๊ด€๋œ ๋ช…๋ช… ๊ทœ์น™
async findByEmail(email: string) { }     // find + By + Field
async findActiveUsers() { }               // find + Adjective + Entities
async getUserStats(userId: number) { }   // get + Noun + Type

// โœ… ์ผ๊ด€๋œ ๋ฐ˜ํ™˜ ํƒ€์ž…
Promise<User | null>      // ๋‹จ์ผ ๋ ˆ์ฝ”๋“œ (nullable)
Promise<User[]>           // ๋‹ค์ค‘ ๋ ˆ์ฝ”๋“œ
Promise<ListResult<User>> // ํŽ˜์ด์ง€๋„ค์ด์…˜

4. Git ๊ด€๋ฆฌ

# ์ƒ์„ฑ๋œ ํŒŒ์ผ ์ปค๋ฐ‹
git add src/models/User.model.ts
git add src/models/User.model.test.ts
git commit -m "Add User model and tests"

๋ฌธ์ œ ํ•ด๊ฒฐ

Model ์žฌ์ƒ์„ฑ

๋ฌธ์ œ: Model์„ ๋‹ค์‹œ ์ƒ์„ฑํ•˜๊ณ  ์‹ถ์Œ ํ•ด๊ฒฐ:
# ๊ธฐ์กด ํŒŒ์ผ ๋ฐฑ์—…
cp src/models/User.model.ts src/models/User.model.ts.bak

# ์žฌ์ƒ์„ฑ
pnpm scaffold model

# ํ•„์š”ํ•œ ์ฝ”๋“œ ๋ณต์›

Entity ๋ณ€๊ฒฝ ํ›„ ๋™๊ธฐํ™”

๋ฌธ์ œ: Entity ๋ณ€๊ฒฝ ํ›„ Model ์—…๋ฐ์ดํŠธ ํ•ด๊ฒฐ:
# 1. ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์ ์šฉ
pnpm migrate run

# 2. Model์€ ์ˆ˜๋™ ์—…๋ฐ์ดํŠธ
# (ํƒ€์ž…์€ ์ž๋™์œผ๋กœ ๋™๊ธฐํ™”๋จ)

๋‹ค์Œ ๋‹จ๊ณ„