generated from ttipo/project-base
feat(backend): 장소 DB 스키마와 마이그레이션 추가
공공데이터 장소(place)와 운영자 큐레이션(place_curation)을 테이블로 나눠 동기화가 사람이 확인한 값을 덮어쓰지 않게 한다. 앱이 보는 값(큐레이션 > 공공 > 미확인)과 노출 규칙은 place_listing 뷰 한 곳에서만 정한다. - 노출 규칙(2026-11-02 합의): 노출 중인 장소는 모두 검색, 홈 추천은 추천(O)+확인일, AI 후보는 1순위 큐레이션 추천·2순위 관광공사 장소. 확인 안 된 상가정보 매장은 AI 후보가 아니다 - 축제·공연·행사(festival_event), 언어별 텍스트(place_i18n), 동기화 기록(place_sync_run) 포함 - 코드값은 place.constants.ts 하나로 CHECK 제약·뷰가 공유 - 전역 SnakeNamingStrategy(DB snake_case ↔ TS camelCase), uuid 는 pgcrypto 임시 Postgres 15 에서 생성 → 적용 → 재생성 무차이 → revert 를 확인했고, 뷰 규칙은 8가지 경우로 검증했다. 설계: docs/design/place-db.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { join } from 'path';
|
||||
import { DataSourceOptions } from 'typeorm';
|
||||
|
||||
import { SnakeNamingStrategy } from '@/shared/config/snake-naming.strategy';
|
||||
import { parsePositiveInt } from '@/shared/utils/env';
|
||||
|
||||
/**
|
||||
@@ -73,6 +74,10 @@ export function buildDatabaseOptions(): DataSourceOptions {
|
||||
migrationsRun: isProduction,
|
||||
logging: !isProduction,
|
||||
extra: { ...POOL_OPTIONS },
|
||||
// 테이블·컬럼은 snake_case, TS 속성은 camelCase — 엔티티마다 name 을 적지 않는다
|
||||
namingStrategy: new SnakeNamingStrategy(),
|
||||
// uuid 기본값을 gen_random_uuid() 로 만든다(pgcrypto, PostgreSQL 13+ 는 기본 내장 함수)
|
||||
uuidExtension: 'pgcrypto',
|
||||
entities: paths.entities,
|
||||
migrations: paths.migrations,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { SnakeNamingStrategy } from './snake-naming.strategy';
|
||||
|
||||
describe('SnakeNamingStrategy', () => {
|
||||
const strategy = new SnakeNamingStrategy();
|
||||
|
||||
it('클래스 이름으로 테이블 이름을 만들고, 직접 지정한 이름은 그대로 쓴다', () => {
|
||||
expect(strategy.tableName('PlaceCuration', undefined)).toBe(
|
||||
'place_curation',
|
||||
);
|
||||
expect(strategy.tableName('FestivalEvent', 'festival_event')).toBe(
|
||||
'festival_event',
|
||||
);
|
||||
});
|
||||
|
||||
it('속성 이름을 snake_case 컬럼으로 바꾸고, 직접 지정한 컬럼 이름은 그대로 쓴다', () => {
|
||||
expect(strategy.columnName('verifiedAt', undefined, [])).toBe(
|
||||
'verified_at',
|
||||
);
|
||||
expect(strategy.columnName('verifiedAt', 'checked_on', [])).toBe(
|
||||
'checked_on',
|
||||
);
|
||||
});
|
||||
|
||||
it('임베디드 접두어를 붙인다', () => {
|
||||
expect(strategy.columnName('lat', undefined, ['geoPoint'])).toBe(
|
||||
'geo_point_lat',
|
||||
);
|
||||
});
|
||||
|
||||
it('관계 조인 컬럼을 snake_case 로 만든다', () => {
|
||||
expect(strategy.joinColumnName('place', 'id')).toBe('place_id');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { DefaultNamingStrategy, NamingStrategyInterface } from 'typeorm';
|
||||
|
||||
import { toSnakeCase } from '@/shared/utils/snake-case';
|
||||
|
||||
/**
|
||||
* DB 이름 규칙 — 테이블·컬럼은 snake_case, TS 속성은 camelCase.
|
||||
* 엔티티마다 `name:` 을 적지 않아도 되게 한 곳에서 바꾼다(앱·CLI 가 database.config.ts 로 공유).
|
||||
* `name:` 을 직접 지정한 경우에는 그 이름을 그대로 쓴다.
|
||||
*/
|
||||
export class SnakeNamingStrategy
|
||||
extends DefaultNamingStrategy
|
||||
implements NamingStrategyInterface
|
||||
{
|
||||
tableName(targetName: string, userSpecifiedName: string | undefined): string {
|
||||
return userSpecifiedName ?? toSnakeCase(targetName);
|
||||
}
|
||||
|
||||
columnName(
|
||||
propertyName: string,
|
||||
customName: string | undefined,
|
||||
embeddedPrefixes: string[],
|
||||
): string {
|
||||
const prefix = toSnakeCase(embeddedPrefixes.concat('').join('_'));
|
||||
return prefix + (customName ?? toSnakeCase(propertyName));
|
||||
}
|
||||
|
||||
relationName(propertyName: string): string {
|
||||
return toSnakeCase(propertyName);
|
||||
}
|
||||
|
||||
joinColumnName(relationName: string, referencedColumnName: string): string {
|
||||
return toSnakeCase(`${relationName}_${referencedColumnName}`);
|
||||
}
|
||||
|
||||
joinTableName(
|
||||
firstTableName: string,
|
||||
secondTableName: string,
|
||||
firstPropertyName: string,
|
||||
): string {
|
||||
return toSnakeCase(
|
||||
`${firstTableName}_${firstPropertyName.replace(/\./g, '_')}_${secondTableName}`,
|
||||
);
|
||||
}
|
||||
|
||||
joinTableColumnName(
|
||||
tableName: string,
|
||||
propertyName: string,
|
||||
columnName?: string,
|
||||
): string {
|
||||
return toSnakeCase(`${tableName}_${columnName ?? propertyName}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { toSnakeCase } from './snake-case';
|
||||
|
||||
describe('toSnakeCase', () => {
|
||||
it('camelCase 속성 이름을 snake_case 컬럼 이름으로 바꾼다', () => {
|
||||
expect(toSnakeCase('sourceId')).toBe('source_id');
|
||||
expect(toSnakeCase('ldongRegnCd')).toBe('ldong_regn_cd');
|
||||
expect(toSnakeCase('publicHoursText')).toBe('public_hours_text');
|
||||
});
|
||||
|
||||
it('PascalCase 클래스 이름을 테이블 이름으로 바꾼다(숫자 포함)', () => {
|
||||
expect(toSnakeCase('PlaceI18n')).toBe('place_i18n');
|
||||
expect(toSnakeCase('PlaceSyncRun')).toBe('place_sync_run');
|
||||
});
|
||||
|
||||
it('대문자 약어가 이어져도 단어 경계를 지킨다', () => {
|
||||
expect(toSnakeCase('HTTPServer')).toBe('http_server');
|
||||
});
|
||||
|
||||
it('이미 snake_case 인 이름은 그대로 둔다', () => {
|
||||
expect(toSnakeCase('place_id')).toBe('place_id');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* camelCase·PascalCase 식별자를 snake_case 로 바꾼다 (DB 테이블·컬럼 이름 규칙용).
|
||||
* 예: sourceId → source_id, PlaceI18n → place_i18n, ldongRegnCd → ldong_regn_cd
|
||||
*/
|
||||
export function toSnakeCase(name: string): string {
|
||||
return (
|
||||
name
|
||||
// 소문자·숫자 뒤의 대문자 앞에 밑줄: sourceId → source_Id
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
||||
// 대문자 연속 뒤에 단어가 시작될 때: HTTPServer → HTTP_Server
|
||||
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
|
||||
.toLowerCase()
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user