메인 μ½˜ν…μΈ λ‘œ κ±΄λ„ˆλ›°κΈ°
sonamuFilterλŠ” Entity의 ν•„λ“œλ₯Ό νƒ€μž… μ•ˆμ „ν•˜κ²Œ ν•„ν„°λ§ν•˜λŠ” κΈ°λŠ₯μž…λ‹ˆλ‹€. ν•„ν„° κ°€λŠ₯ν•œ ν•„λ“œμ™€ ν—ˆμš©λ˜λŠ” μ—°μ‚°μžκ°€ Entity μ •μ˜μ—μ„œ μžλ™μœΌλ‘œ μΆ”λ‘ λ˜μ–΄ 잘λͺ»λœ 필터링 μ‹œλ„λ₯Ό νƒ€μž… 레벨과 λŸ°νƒ€μž„ λͺ¨λ‘μ—μ„œ λ°©μ§€ν•©λ‹ˆλ‹€.
sonamuFilterλŠ” Entity의 propsλ₯Ό 기반으둜 μžλ™ μƒμ„±λ©λ‹ˆλ‹€. 가상 ν•„λ“œ(virtual fields)λŠ” 필터링 λŒ€μƒμ—μ„œ μ œμ™Έλ˜λ©°, FKλ₯Ό μƒμ„±ν•˜λŠ” 관계(BelongsToOne, hasJoinColumn이 μžˆλŠ” OneToOne)λŠ” {관계λͺ…}_id ν˜•νƒœλ‘œ 필터링할 수 μžˆμŠ΅λ‹ˆλ‹€.

κΈ°λ³Έ μ‚¬μš©λ²•

// λ‹¨μˆœ κ°’ ν•„ν„° (eq와 동일)
const { rows } = await ProjectModel.findMany("A", {
  sonamuFilter: {
    status: "in_progress"
  }
});

// μ—°μ‚°μž μ‚¬μš©
const { rows } = await ProjectModel.findMany("A", {
  sonamuFilter: {
    budget: { gt: 10000 },
    name: { contains: "AI" }
  }
});

ν•„ν„° νƒ€μž…

FilterQuery

각 Entity에 λŒ€ν•΄ FilterQuery<T> νƒ€μž…μ΄ μžλ™ μƒμ„±λ©λ‹ˆλ‹€:
// μžλ™ μƒμ„±λœ νƒ€μž… (sonamu.generated.ts)
export type ProjectListParams = {
  // ...κΈ°μ‘΄ νŒŒλΌλ―Έν„°λ“€
  sonamuFilter?: FilterQuery<FilterNumericOverride<ProjectBaseSchema>>;
};

FilterCondition

ν•„λ“œ νƒ€μž…μ— 따라 μ‚¬μš© κ°€λŠ₯ν•œ μ—°μ‚°μžκ°€ κ²°μ •λ©λ‹ˆλ‹€:
type FilterCondition<T> =
  | T                           // 직접 κ°’ (eq와 동일)
  | { eq?: T }                  // κ°™μŒ
  | { ne?: T }                  // κ°™μ§€ μ•ŠμŒ
  | { gt?: T }                  // 보닀 큼
  | { gte?: T }                 // 보닀 ν¬κ±°λ‚˜ κ°™μŒ
  | { lt?: T }                  // 보닀 μž‘μŒ
  | { lte?: T }                 // 보닀 μž‘κ±°λ‚˜ κ°™μŒ
  | { in?: T[] }                // 포함
  | { notIn?: T[] }             // 미포함
  | { between?: [T, T] }        // λ²”μœ„
  | { contains?: string }       // 포함 (λ¬Έμžμ—΄)
  | { startsWith?: string }     // μ‹œμž‘ (λ¬Έμžμ—΄)
  | { endsWith?: string }       // 끝 (λ¬Έμžμ—΄)
  | { isNull?: boolean }        // NULL μ—¬λΆ€
  | { isNotNull?: boolean }     // NOT NULL μ—¬λΆ€
  | { before?: Date }           // 이전 (λ‚ μ§œ)
  | { after?: Date }            // 이후 (λ‚ μ§œ)

νƒ€μž…λ³„ 지원 μ—°μ‚°μž

νƒ€μž…μ§€μ› μ—°μ‚°μž
stringeq, ne, contains, startsWith, endsWith, in, notIn, isNull, isNotNull
integereq, ne, gt, gte, lt, lte, in, notIn, between, isNull, isNotNull
numericeq, ne, gt, gte, lt, lte, in, notIn, between, isNull, isNotNull
booleaneq, ne, isNull, isNotNull
dateeq, ne, before, after, between, isNull, isNotNull
datetimeeq, ne, before, after, between, isNull, isNotNull
enumeq, ne, in, notIn, isNull, isNotNull
jsonisNull, isNotNull

μ‚¬μš© μ˜ˆμ‹œ

숫자 필터링

// μ •ν™•ν•œ κ°’
const { rows } = await ProjectModel.findMany("A", {
  sonamuFilter: { id: 1 }
});

// 비ꡐ μ—°μ‚°μž
const { rows } = await ProjectModel.findMany("A", {
  sonamuFilter: {
    budget: { gt: 10000 }  // budget > 10000
  }
});

// λ²”μœ„ 쑰건
const { rows } = await ProjectModel.findMany("A", {
  sonamuFilter: {
    budget: { between: [5000, 20000] }  // 5000 <= budget <= 20000
  }
});

// μ—¬λŸ¬ κ°’ 쀑 ν•˜λ‚˜
const { rows } = await ProjectModel.findMany("A", {
  sonamuFilter: {
    id: { in: [1, 2, 3] }
  }
});

λ¬Έμžμ—΄ 필터링

// λΆ€λΆ„ 일치 (LIKE '%keyword%')
const { rows } = await ProjectModel.findMany("A", {
  sonamuFilter: {
    name: { contains: "AI" }
  }
});

// 접두사 일치 (LIKE 'keyword%')
const { rows } = await ProjectModel.findMany("A", {
  sonamuFilter: {
    name: { startsWith: "Project" }
  }
});

// 접미사 일치 (LIKE '%keyword')
const { rows } = await ProjectModel.findMany("A", {
  sonamuFilter: {
    name: { endsWith: "2024" }
  }
});

λ‚ μ§œ 필터링

// νŠΉμ • λ‚ μ§œ 이전
const { rows } = await ProjectModel.findMany("A", {
  sonamuFilter: {
    deadline: { before: new Date("2024-12-31") }
  }
});

// νŠΉμ • λ‚ μ§œ 이후
const { rows } = await ProjectModel.findMany("A", {
  sonamuFilter: {
    created_at: { after: new Date("2024-01-01") }
  }
});

// λ‚ μ§œ λ²”μœ„
const { rows } = await ProjectModel.findMany("A", {
  sonamuFilter: {
    deadline: {
      between: [new Date("2024-01-01"), new Date("2024-12-31")]
    }
  }
});

Enum 필터링

// 단일 κ°’
const { rows } = await ProjectModel.findMany("A", {
  sonamuFilter: {
    status: "in_progress"
  }
});

// μ—¬λŸ¬ κ°’ 쀑 ν•˜λ‚˜
const { rows } = await ProjectModel.findMany("A", {
  sonamuFilter: {
    status: { in: ["planning", "in_progress"] }
  }
});

// νŠΉμ • κ°’ μ œμ™Έ
const { rows } = await ProjectModel.findMany("A", {
  sonamuFilter: {
    status: { notIn: ["cancelled", "completed"] }
  }
});

NULL 체크

// NULL인 경우
const { rows } = await ProjectModel.findMany("A", {
  sonamuFilter: {
    description: { isNull: true }
  }
});

// NULL이 μ•„λ‹Œ 경우
const { rows } = await ProjectModel.findMany("A", {
  sonamuFilter: {
    description: { isNotNull: true }
  }
});

볡합 쑰건 (AND)

// μ—¬λŸ¬ ν•„λ“œ 쑰건은 AND둜 κ²°ν•©λ©λ‹ˆλ‹€
const { rows } = await ProjectModel.findMany("A", {
  sonamuFilter: {
    status: { in: ["planning", "in_progress"] },
    budget: { gt: 5000 },
    name: { contains: "AI" }
  }
});
// WHERE status IN ('planning', 'in_progress')
//   AND budget > 5000
//   AND name LIKE '%AI%'

FK (Foreign Key) 필터링

FKλ₯Ό μƒμ„±ν•˜λŠ” 관계(BelongsToOne, hasJoinColumn이 μžˆλŠ” OneToOne)λŠ” {관계λͺ…}_id ν˜•νƒœλ‘œ 필터링할 수 μžˆμŠ΅λ‹ˆλ‹€. λ‚΄λΆ€μ μœΌλ‘œ integer νƒ€μž…μ˜ 가상 prop으둜 λ³€ν™˜λ˜μ–΄ λͺ¨λ“  숫자 μ—°μ‚°μžλ₯Ό μ§€μ›ν•©λ‹ˆλ‹€.
// νŠΉμ • 직원이 λ‹΄λ‹Ήν•˜λŠ” ν”„λ‘œμ νŠΈ 쑰회
const { rows } = await ProjectModel.findMany("A", {
  sonamuFilter: {
    employee_id: 5  // employee κ΄€κ³„μ˜ FK
  }
});

// μ—¬λŸ¬ 직원이 λ‹΄λ‹Ήν•˜λŠ” ν”„λ‘œμ νŠΈ 쑰회
const { rows } = await ProjectModel.findMany("A", {
  sonamuFilter: {
    employee_id: { in: [1, 2, 3] }
  }
});

// λ‹΄λ‹Ήμžκ°€ μ—†λŠ” ν”„λ‘œμ νŠΈ 쑰회
const { rows } = await ProjectModel.findMany("A", {
  sonamuFilter: {
    employee_id: { isNull: true }
  }
});

// FK와 λ‹€λ₯Έ 쑰건 μ‘°ν•©
const { rows } = await ProjectModel.findMany("A", {
  sonamuFilter: {
    employee_id: { in: [1, 2, 3] },
    status: "in_progress",
    budget: { gt: 10000 }
  }
});
FK 필터링은 관계λͺ…이 μ•„λ‹Œ {관계λͺ…}_id ν˜•μ‹μ„ μ‚¬μš©ν•΄μ•Ό ν•©λ‹ˆλ‹€. 예λ₯Ό λ“€μ–΄ employee 관계가 μžˆλ‹€λ©΄ employeeκ°€ μ•„λ‹Œ employee_id둜 ν•„ν„°λ§ν•©λ‹ˆλ‹€.

URL 쿼리 슀트링

ν”„λ‘ νŠΈμ—”λ“œμ—μ„œ URL 쿼리 슀트링으둜 ν•„ν„°λ₯Ό 전달할 수 μžˆμŠ΅λ‹ˆλ‹€:
GET /api/projects?sonamuFilter[status]=in_progress&sonamuFilter[budget][gt]=10000
Fastifyκ°€ νŒŒμ‹±ν•œ λ¬Έμžμ—΄μ€ μžλ™μœΌλ‘œ μ μ ˆν•œ νƒ€μž…μœΌλ‘œ λ³€ν™˜λ©λ‹ˆλ‹€:
// URLμ—μ„œ μ „λ‹¬λœ κ°’
{ status: "in_progress", budget: { gt: "10000" } }

// μžλ™ λ³€ν™˜ ν›„
{ status: "in_progress", budget: { gt: 10000 } }

μœ νš¨μ„± 검증

sonamuFilterλŠ” λ‹€μŒμ„ κ²€μ¦ν•©λ‹ˆλ‹€:

1. ν•„ν„° κ°€λŠ₯ν•œ ν•„λ“œ

Entity의 props에 μ •μ˜λœ ν•„λ“œλ§Œ 필터링할 수 μžˆμŠ΅λ‹ˆλ‹€. 가상 ν•„λ“œλŠ” 필터링 λŒ€μƒμ—μ„œ μ œμ™Έλ©λ‹ˆλ‹€. 단, FKλ₯Ό μƒμ„±ν•˜λŠ” 관계(BelongsToOne, hasJoinColumn이 μžˆλŠ” OneToOne)λŠ” {관계λͺ…}_id ν˜•νƒœλ‘œ 필터링할 수 μžˆμŠ΅λ‹ˆλ‹€.
// βœ… FKλ₯Ό μƒμ„±ν•˜λŠ” κ΄€κ³„λŠ” {관계λͺ…}_id둜 필터링 κ°€λŠ₯
await ProjectModel.findMany("A", {
  sonamuFilter: { employee_id: { in: [1, 2, 3] } }  // employee κ΄€κ³„μ˜ FK
});

// ❌ μ—λŸ¬: ν•„λ“œ 'employee'λŠ” 필터링할 수 μ—†μŠ΅λ‹ˆλ‹€ (관계λͺ… μžμ²΄λŠ” λΆˆκ°€)
await ProjectModel.findMany("A", {
  sonamuFilter: { employee: { eq: 1 } }
});

// ❌ μ—λŸ¬: ν•„λ“œ 'virtual_test'λŠ” 필터링할 수 μ—†μŠ΅λ‹ˆλ‹€
await ProjectModel.findMany("A", {
  sonamuFilter: { virtual_test: { eq: 1 } }  // 가상 ν•„λ“œ
});

2. μ§€μ›ν•˜λŠ” μ—°μ‚°μž

ν•„λ“œ νƒ€μž…μ— λ§žλŠ” μ—°μ‚°μžλ§Œ μ‚¬μš©ν•  수 μžˆμŠ΅λ‹ˆλ‹€.
// ❌ μ—λŸ¬: ν•„λ“œ 'name'(νƒ€μž…: string)λŠ” 'between' μ—°μ‚°μžλ₯Ό μ§€μ›ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€
await ProjectModel.findMany("A", {
  sonamuFilter: { name: { between: ["a", "z"] } }
});

3. Enum κ°’

Enum νƒ€μž…μ˜ ν•„λ“œλŠ” μ •μ˜λœ κ°’λ§Œ ν—ˆμš©ν•©λ‹ˆλ‹€.
// ❌ μ—λŸ¬: ν•„λ“œ 'status'의 κ°’ 'invalid_status'λŠ” μœ νš¨ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€
await ProjectModel.findMany("A", {
  sonamuFilter: { status: "invalid_status" }
});

κΈ°μ‘΄ ν•„ν„°μ™€μ˜ 관계

sonamuFilterλŠ” κΈ°μ‘΄ ListParams의 μ»€μŠ€ν…€ 필터와 ν•¨κ»˜ μ‚¬μš©ν•  수 μžˆμŠ΅λ‹ˆλ‹€:
const { rows } = await ProjectModel.findMany("A", {
  // κΈ°μ‘΄ μ»€μŠ€ν…€ ν•„ν„°
  id: [1, 2, 3],
  status: "active",

  // sonamuFilter
  sonamuFilter: {
    budget: { gt: 10000 },
    name: { contains: "AI" }
  }
});
κΈ°μ‘΄ 필터와 sonamuFilterκ°€ 같은 ν•„λ“œλ₯Ό ν•„ν„°λ§ν•˜λ©΄ 두 쑰건이 λͺ¨λ‘ AND둜 μ μš©λ©λ‹ˆλ‹€. μ˜λ„μΉ˜ μ•Šμ€ κ²°κ³Όλ₯Ό λ°©μ§€ν•˜λ €λ©΄ 같은 ν•„λ“œμ— λŒ€ν•œ 쀑볡 필터링을 ν”Όν•˜μ„Έμš”.

λ‚΄λΆ€ λ™μž‘

1. ν•„ν„° μ •κ·œν™” (normalizeFilterQuery)

URL 쿼리 슀트링으둜 μ „λ‹¬λœ λ¬Έμžμ—΄ 값을 μ μ ˆν•œ νƒ€μž…μœΌλ‘œ λ³€ν™˜ν•©λ‹ˆλ‹€.
normalizeFilterQuery({ budget: { gt: "10000" } })
// β†’ { budget: { gt: 10000 } }

2. μœ νš¨μ„± 검증 (validateSonamuFilters)

Entity의 ν•„ν„° 메타데이터λ₯Ό 기반으둜 ν•„ν„° 쿼리λ₯Ό κ²€μ¦ν•©λ‹ˆλ‹€.

3. 쿼리 적용 (applySonamuFilters)

κ²€μ¦λœ ν•„ν„° 쑰건을 Puri 쿼리 λΉŒλ”μ— μ μš©ν•©λ‹ˆλ‹€.
// BaseModelClass λ‚΄λΆ€ κ΅¬ν˜„
protected applySonamuFilters<TEntity>(
  qb: Puri<any, any, any>,
  filters?: FilterQuery<TEntity>,
): void {
  if (!filters) return;

  const entity = EntityManager.get(this.modelName);
  const filterableProps = entity.getFilterableProps();

  // 1. ν•„ν„° 검증
  validateSonamuFilters(filters, filterableProps);

  // 2. ν•„ν„° 적용
  for (const [field, condition] of Object.entries(filters)) {
    const fullField = entity.getFullFieldName(field);

    if (typeof condition !== "object") {
      qb.where(fullField, condition);
    } else {
      for (const [operator, value] of Object.entries(condition)) {
        this.applyOperator(qb, fullField, operator, value);
      }
    }
  }
}

λ‹€μŒ 단계