Skip to content

feat(exporter): Drizzle ORM exporter 추가 - #186

Open
yyuneu wants to merge 13 commits into
dev-five-git:mainfrom
yyuneu:feat/drizzle-exporter
Open

feat(exporter): Drizzle ORM exporter 추가#186
yyuneu wants to merge 13 commits into
dev-five-git:mainfrom
yyuneu:feat/drizzle-exporter

Conversation

@yyuneu

@yyuneu yyuneu commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

개요

6번째 ORM 백엔드로 Drizzle(TypeScript)을 추가합니다.
Drizzle은 백엔드 중립 표현이 없어서, 한 번의 export가 방언별 파일 3개를 씁니다.

vespertide export --orm drizzle
# → src/models/models.pg.ts, models.mysql.ts, models.sqlite.ts

완성 기준은 vespertide로 만든 DB에 drizzle-kit push를 돌려도 변경이 없어야 한다로 잡았습니다.
도커(postgres:17 / mysql:8)에 vespertide가 생성한 DDL을 적용하고 push를 반복해 확인했습니다.

설계상 특이사항

1. 출력이 방언별 3파일인 이유

Drizzle에는 백엔드 중립 표현이 없습니다. pgTable / mysqlTable / sqliteTable이 서로 다른 패키지(drizzle-orm/pg-core 등)에 있어 import 라인에서 갈라지고, 타입 생성자와 기본값 표기도 방언마다 다릅니다. Prisma처럼 중립 단일 파일을 만들 수 없어 한 번의 export가 방언당 한 파일을 씁니다. OrmExporter 트레이트 경로(크로스-ORM 비교용 단일 String)는 PostgreSQL을 정본으로 렌더합니다.

2. 이름·타입·기본값 철자를 전부 SQL 계층에 맞춘 이유

drizzle-kit push는 모델과 DB를 사실상 문자열 수준으로 비교하므로, 모델은 vespertide가 실제로 만든 것을 그대로 말해야 합니다. 제약 이름은 SQL 계층과 같은 vespertide-naming 빌더(build_unique_constraint_name / build_index_name / build_foreign_key_name / build_enum_type_name)를 사용하고, 나머지 규칙은 왕복에서 드리프트 문장이 나온 항목을 하나씩 제거하며 도출했습니다:

항목 규칙 근거(실측)
unique uniqueIndex(): 컬럼 체인 .unique() 금지 SQL 계층은 전부 CREATE UNIQUE INDEX. unique()는 제약으로 introspect되어 kit가 드랍 후 재생성
pg auto-increment .generatedByDefaultAsIdentity() SQL 계층은 IDENTITY. serial이면 kit가 DROP IDENTITY를 시도
복합 PK 이름 pg {table}_pkey / mysql {table}_{cols} 이름이 없으면 kit가 PK를 드랍·재추가하려다 FK 의존으로 실패까지 함
pg enum 타입명 항상 {table}_{enum}, 테이블당 pgEnum 1개 SQL 계층의 CREATE TYPE이 항상 테이블-프리픽스. Prisma처럼 전역 dedup하면 타입명 자체가 드리프트
bytea / xml / custom 타입 customType 헬퍼 text로 넓히면 실타입 드리프트. bytea는 Uint8Array@types/node 없이 컴파일
타임스탬프 기본값 pg sql`CURRENT_TIMESTAMP` / mysql .defaultNow() / sqlite sql`(CURRENT_TIMESTAMP)` 방언마다 무드리프트 철자가 정확히 하나씩이고, pg와 mysql이 반대 방향
mysql uuid 기본값 sql`(uuid())` 소문자 information_schema가 소문자로 저장하고 kit 비교는 대소문자 민감
sqlite FK 무명 sqlite는 FK 이름을 저장하지 않음. 이름을 넣으면 영구 드리프트

3. FK가 .references() 체인이 아니라 foreignKey() 연산자인 이유

체인이 못 하는 것 세 가지 때문입니다: 제약 이름 운반(위 표), 복합키, 자기참조(foreignColumns를 콜백의 t에서 가져와 테이블 const가 자기 초기화식 타입 추론에 들어가는 것을 회피).

4. 파일-스코프 바인딩 충돌 처리 (drizzle/bindings.rs)

한 파일에 top-level const가 4종(customType / pgEnum / 테이블 / {table}Relations) 공존하고, import 심볼·콜백 파라미터와도 네임스페이스를 공유합니다. to_camel_case_-를 접기 때문에 서로 다른 DB 이름이 한 바인딩으로 겹칠 수 있고(user_relations 테이블 vs user의 relations 블록), 테이블명이 import(sql, integer)나 콜백 파라미터(t, one, many)와 같을 수도 있습니다. 특히 t가 겹치면 FK의 foreignColumns가 콜백 파라미터로 해석되어 컴파일은 되는데도 틀린 출력이 됩니다. 그래서 선언 순서대로 바인딩을 claim하고 충돌 시 숫자 접미사를 붙이며, 모든 참조가 확정된 이름을 따라갑니다. 충돌이 없는 스키마의 출력은 바이트 단위로 동일합니다.

5. drizzle export가 확장자 글롭 클린을 쓰지 않는 이유

기존 export 경로는 재생성 전에 export 디렉터리에서 해당 확장자 파일을 재귀 삭제합니다. drizzle의 확장자는 .ts인데, export 루트(기본 src/models)가 사용자 소스 디렉터리와 겹치는 프로젝트에서는 사용자가 직접 쓴 파일까지 지워집니다. 출력 파일명이 3개로 고정이라 덮어쓰기로 충분하므로 drizzle 경로는 클린을 생략하게 했습니다.

테스트 설계

  • 새 export 시나리오는 공용 orm_cases! 픽스처 1개 + 매크로 1줄로 6-ORM 스냅샷을 만드는 기존 규칙을 따랐습니다. Drizzle 추가 자체는 각 매크로에 #[case::drizzle] 1줄
  • 방언 축은 단일 백엔드 전용 진입점(render_schema(tables, dialect))이라 Prisma-exception 패턴대로 모듈 인라인 스냅샷(스냅샷 파일은 공유 src/tests/snapshots/)
  • types.rs는 전 타입 × 3방언 rstest 매트릭스(기대 문자열 직접 명시), render.rs는 default_chain·FK entry 등 분기 단위 테스트
  • 바인딩 어휘 가드: 렌더된 import 라인을 파싱해 어휘 누락을 기계적으로 검출
  • 수치: exporter 테스트 960 / 스냅샷 421 (Drizzle 크로스-ORM 69 + 방언 풀파일 3)

렌더링 밖 검증:

  • 도커 왕복: vespertide DDL 적용 → drizzle-kit push 반복 → 수렴 확인 (개요의 결과)
  • 110테이블 스키마: export 0.6s, 3방언 tsc --strict 무오류
  • 픽스처 전수(53개 × 3방언) strict tsc 실패는 전부 "스키마에 없는 테이블로의 FK" 픽스처로, export가 참조를 검증하지 않는 기존 동작 그대로 입니다.

함께 들어간 변경

  • Drizzle과 Prisma가 공유하게 된 로직을 공용 모듈로 추출했습니다: enum_scan(테이블 enum 수집), constraint_scan(FK 관계 네이밍·역관계 수집), utils/common(필드명 claim). Prisma는 위임으로 전환했고 기존 5개 백엔드의 스냅샷은 전부 무변경입니다
  • vespertide-namingto_camel_case / infer_relation_field_name을 추가했습니다 (JPA의 _id 스트립도 후자로 위임)
  • CLI export의 "클린 + 디렉터리 생성" 전처리를 prepare_export_dir로 공용화했습니다
  • vespertide-exporter 0.3.0 → 0.4.0: Orm이 exhaustive pub enum이라 변형 추가가 breaking입니다(cargo-semver-checks 기준). Orm#[non_exhaustive]를 붙여 이후 백엔드 추가를 minor로 만들지는 API 계약 판단이라 이번에는 손대지 않았습니다
  • Cargo.lock 갱신

한계

  • sqlite 무드리프트는 구조적으로 불가: drizzle-kit sqlite는 타입명 문자열을 비교하는데, vespertide sqlite 타입(timestamp_text, enum_text 등)은 sqlite-core 생성자로 표현할 수 없습니다. 런타임 동작(타입 affinity)은 문제가 없어 문서화로 종결했습니다
  • 왕복 검증 중 drizzle-kit 자체 버그 2건을 발견해 kit가 스스로 생성한 스키마만으로 재현해 두었습니다(비-PK unique 컬럼을 참조하는 FK가 있으면 kit 자신의 스키마도 첫 push에 실패하는 문장 순서 문제 / introspect가 FK가 의존하는 피참조 테이블의 unique index를 constraint-generated로 오분류해 영구 드리프트).

@owjs3901

Copy link
Copy Markdown
Contributor

한 번의 export가 방언별 파일 3개를 씁니다 이 솔루션 정말 감동적이며 혁명적입니다

제 발상을 뒤집네요 너무 좋습니다

Comment on lines +20 to +25
assert!(pg.contains("pgTable(\"events\""));
assert!(pg.contains("from \"drizzle-orm/pg-core\""));
assert!(mysql.contains("mysqlTable(\"events\""));
assert!(mysql.contains("from \"drizzle-orm/mysql-core\""));
assert!(sqlite.contains("sqliteTable(\"events\""));
assert!(sqlite.contains("from \"drizzle-orm/sqlite-core\""));

@owjs3901 owjs3901 Aug 27, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이런건 차라리 snapshot 테스트가 맞다고 생각해요

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

말씀대로 방언별 rstest 케이스 + 케이스마다 스냅샷으로 바꿨습니다.

contains로 볼 때는 안 보이던 차이가 스냅샷에는 그대로 남습니다. 같은 integer 컬럼이 pg/sqlite에서는 integer, mysql에서는 int로 나가는 것 같은 부분입니다.

@owjs3901 owjs3901 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

snapshot에 각각 postgresql과 sqlite 등 따로따로 있어야 하는게 아닌가 싶습니다

"export {};"
);
let pg = std_fs::read_to_string(root.join("models.pg.ts")).unwrap();
assert!(pg.contains("pgTable(\"events\""));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

snapshot 테스트가 맞다고 생각합니다

rstest로 각 db에 대해서 따로따로 돌면 예쁠것 같네요

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 테스트도 방언별 rstest로 나눴습니다. 케이스마다 그 방언의 출력 파일을 stale 상태로 심어두고, export 후 덮어써졌는지와 사용자가 직접 쓴 .ts가 살아있는지를 봅니다.

여기서는 스냅샷을 쓰지 않았습니다. 이 테스트의 주제가 사용자 파일 생존과 덮어쓰기라는 동작이고, 파일 내용은 바로 위 스냅샷 테스트가 이미 방언별로 고정하고 있어서 같은 내용을 두 벌로 들고 있을 이유가 없다고 봤습니다.

@yyuneu

yyuneu commented Aug 29, 2026

Copy link
Copy Markdown
Contributor Author

방언별로 따로 두도록 전부 바꿨습니다.

  • CLI export: 출력 검사와 사용자 파일 보존 테스트 둘 다 pg / mysql / sqlite 케이스로
  • exporter: enum 선언과 바인딩 충돌 시나리오도 방언별 스냅샷으로
  • 콜백 스코프 충돌만 pg 하나입니다. 픽스처가 전부 integer 테이블이라 방언별로 출력이 갈리지 않아서, 같은 스냅샷 세 벌은 노이즈라고 봤습니다

바인딩 충돌은 나눠놓고 보니 mysql만 integer 커스텀 타입에 접미사가 안 붙는 게 드러납니다. mysql 생성자가 int라 이름이 겹치지 않아서인데, 합쳐놨을 때는 안 보이던 부분입니다.

기준이 문서에 없어서 이런 차이가 생긴 것 같아 AGENTS.md에 정리해뒀습니다.

  • 생성된 텍스트(렌더 결과, 커맨드가 쓴 파일)는 스냅샷으로 고정합니다.
  • 파일 생존이나 경로 파생 같은 동작·구조 사실은 직접 단언합니다.
  • 케이스 분리는 두 갈래입니다. 백엔드 매트릭스처럼 문서화된 강제가 있는 축이면 출력이 같아도 무조건 나눕니다. 세 방언이 같은 바이트를 낸다는 것 자체가 단언이니까요. 그런 강제가 없는 축은 출력이 실제로 달라질 때만 나눕니다.

스냅샷에 남아 있던 assertion_line 메타데이터도 함께 정리했습니다. 수동 accept 과정에서 붙은 것인데 main 쪽 스냅샷에는 거의 없어서 맞췄습니다.

마지막 커밋(chore: 최신 stable clippy 대응)은 이 PR 내용과 무관한데, 툴체인이 올라가면서 clippy 잡이 깨져서 같이 넣었습니다. rust-toolchain.toml이 stable을 핀 없이 따라가는데 CI가 rustc 1.98을 집으면서 세 가지가 걸립니다.

  • planner 세 파일의 #![expect(clippy::doc_markdown)]이 불발 상태가 됩니다. 1.98 clippy가 그 문서들에 더 이상 발화하지 않아서요. 워크스페이스에 이미 doc_markdown = allow가 있어서 attribute만 지우면 됩니다.
  • unused_async_trait_impl이 새로 생겨서 lsp 핸들러 5개에 붙습니다. 그 impl 블록 핸들러 24개 중 19개는 실제로 await를 쓰기 때문에 5개만 impl Future 반환으로 바꾸면 블록이 두 가지 형태로 갈립니다. 워크스페이스 lint 정책 쪽에 한 줄로 두는 게 낫다고 봤는데, 시그니처를 바꾸는 쪽이 맞다고 보시면 수정 하겠습니다.
  • needless_late_init이 if/else-if 체인까지 잡게 돼서 code_actions의 늦은 초기화 하나를 튜플 바인딩으로 정리했습니다.

@yyuneu
yyuneu requested a review from owjs3901 August 29, 2026 10:51
Comment on lines +19 to +29
pub(super) fn enum_db_name(table: &str, enum_name: &str) -> String {
build_enum_type_name(table, enum_name)
}

/// The natural `const` binding for an enum declaration, derived from the
/// database type name so the two stay recognisably paired. The final binding
/// comes from `FileBindings`, which suffixes this name on a file-scope
/// collision.
pub(super) fn enum_const_name(table: &str, enum_name: &str) -> String {
super::js_name(&enum_db_name(table, enum_name))
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

한줄 짜리 함수 지양해야 합니다

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

enum_db_name은 지웠습니다. 인자도 반환도 그대로 넘기기만 하던 별칭이라 호출하는 두 곳에서 build_enum_type_name을 직접 부르게 했고, 거기 달려 있던 SQL 계층이 모든 enum 타입을 테이블-프리픽스로 만들기 때문에 모델도 그래야 한다는 근거는 모듈 doc으로 올렸습니다.

바로 아래 enum_const_name은 남겼습니다. 이쪽은 별칭이 아니라 enum 바인딩 이름은 DB 타입명을 camelCase로 바꾼 것"이라는 규칙 자체이고, bindings.rs가 claim할 때와 폴백할 때 양쪽에서 씁니다. 인라인하면 규칙이 두 곳으로 갈려서 한쪽만 고쳐지는 상황이 생길 수 있어서요.

Comment thread Cargo.toml Outdated
vespertide-planner = { path = "crates/vespertide-planner", version = "=0.3.0" }
vespertide-query = { path = "crates/vespertide-query", version = "=0.3.0" }
vespertide-exporter = { path = "crates/vespertide-exporter", version = "=0.3.0" }
vespertide-exporter = { path = "crates/vespertide-exporter", version = "=0.4.0" }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

혼자 0.4.0인데 개선 혹은 확인이 필요합니다.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

제가 손으로 올린 건데, CI의 semver 주석대로 feature PR에서 버전을 올리지 않고 changepack이 머지 시 올리는 구조더라고요.

핀을 =0.3.0으로 되돌리고 .changepacks/에 descriptor를 넣었습니다.

Comment thread crates/vespertide-exporter/Cargo.toml Outdated
[package]
name = "vespertide-exporter"
version = "0.3.0"
version = "0.4.0"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

확인 및 수정이 필요합니다

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0.3.0으로 되돌렸습니다. 위 핀과 같은 원인이었고, 버전은 changepack descriptor로만 올라가게 했습니다.

source: crates/vespertide-exporter/src/tests/mod.rs
expression: rendered
---
export const events = pgTable("events", {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mysql snapshot은 어디에 있나요? 정확히는 파일이름에 psql이라는 것이 안붙어 있다보니까 불안합니다

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

파일명에 방언을 넣었습니다. 크로스-ORM 스냅샷 69개를 ..._Drizzle.snap..._Drizzle_pg.snap으로 바꿨습니다.

이쪽이 pg 하나뿐인 이유는, 크로스-ORM 하니스가 부르는 OrmExporter 트레이트가 String 하나를 반환해서 방언을 고를 자리가 없기 때문입니다. 그래서 트레이트 경로는 pg를 정본으로 렌더하고, 세 방언 비교는 drizzle 모듈 자체 스냅샷(render_schema_full_file_per_dialect@{pg,mysql,sqlite})이 맡고 있습니다.

라벨은 DrizzleDialect::Pg.file_suffix()에서 파생시켜서 문자열을 따로 관리하지 않아도 표기가 어긋나지 않게 했고, 나머지 두 방언이 어디 있는지는 헬퍼 주석과 exporter AGENTS.md에도 적어뒀습니다.

@yyuneu
yyuneu requested a review from owjs3901 August 30, 2026 12:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants