메인 μ½˜ν…μΈ λ‘œ κ±΄λ„ˆλ›°κΈ°
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/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/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").table("users").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").table("products").where("category_id", categoryId).where("deleted_at", null);
  }

  // κ°€κ²©λŒ€λ³„ μƒν’ˆ 쑰회
  async findByPriceRange(min: number, max: number) {
    return this.getPuri("r").table("products").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").table("users").where("email", email).first();
  }

  // ν™œμ„± μ‚¬μš©μžλ§Œ 쑰회
  async findActiveUsers() {
    return this.getPuri("r").table("users").where("is_active", true).where("deleted_at", null);
  }

  // μ‚¬μš©μž 톡계
  async getUserStats(userId: number) {
    const [stats] = await this.getPuri("r")
      .table("users")
      .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("user1@example.com");
    expect(user).toBeDefined();
  });

  test("findByEmail - μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” 이메일", async () => {
    const user = await UserModel.findByEmail("notexist@example.com");
    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은 μˆ˜λ™ μ—…λ°μ΄νŠΈ
# (νƒ€μž…μ€ μžλ™μœΌλ‘œ 동기화됨)

λ‹€μŒ 단계

Model

Model 클래슀 μžμ„Ένžˆ μ•Œμ•„λ³΄κΈ°

Testing

ν…ŒμŠ€νŠΈ μž‘μ„±ν•˜κΈ°