Skip to content

feat(exporter): Prisma ORM exporter 추가 (PR #151 이어받음) - #166

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

feat(exporter): Prisma ORM exporter 추가 (PR #151 이어받음)#166
yyuneu wants to merge 43 commits into
dev-five-git:mainfrom
yyuneu:feat/prisma-exporter

Conversation

@yyuneu

@yyuneu yyuneu commented Jul 6, 2026

Copy link
Copy Markdown

개요

5번째 ORM 백엔드로 Prisma를 추가합니다 (기존: SeaORM / SQLAlchemy / SQLModel / JPA).
TableDef 스키마를 datasource, generator, enum 블록(전역 dedup), model 블록을
포함한 단일 schema.prisma 파일로 변환합니다.

실행:

vespertide export --orm prisma

설계상 특이사항

1. 왜 Prisma만 별도 config (PrismaConfig)를 가지는가

Prisma는 단일 스키마 언어입니다. datasource db { provider, url, relationMode }
generator client { provider, output }이 모델과 같은 파일 안에 있어야 합니다.
나머지 ORM 백엔드(SeaORM / SQLAlchemy / SQLModel / JPA)는 모델 단위 코드만
생성하므로 이런 메타 정보가 필요 없습니다. 즉, Prisma만 "모델 외 입력"(provider,
client output 경로, relation mode)을 받아야 하기 때문에 vespertide-config
PrismaConfig를 새로 추가했습니다. 필드 세 개(provider / client_output /
relation_mode)와 postgresql 기본값을 가집니다.

(2026-07-07 갱신) 이 절은 원본 PR 제출 당시 설계입니다. 아래 "리뷰 반영" 절에서
설명하듯, 리뷰 피드백을 받아 필드 3개 중 2개(provider / clientOutput)는 제거하고
나머지 하나(relationMode)는 enum으로 바꿨습니다. 현재는 relation_mode 필드 하나만
남아 있습니다.

2. 왜 export.rsif matches!(orm, OrmArg::Prisma) { return cmd_export_prisma(...) } 분기가 있는가

기존 export 파이프라인은 모델 N개 → 파일 N개 전제입니다:
walk_models → 모델별 render_entity_with_schema → 파일별 write → mod chain.
Prisma는 모델 N개 → 파일 1개이며 위 파이프라인이 만족시킬 수 없는 두 가지
요구가 있습니다:

  • 모든 테이블에 걸친 enum 전역 dedup (두 테이블이 같은 enum을 참조해도 enum
    블록은 파일에 한 번만 등장해야 함)
  • datasource/generator 헤더가 파일 맨 위에 1회만 출력되어야 함

그래서 cmd_export가 진입 즉시 cmd_export_prisma로 분기하고,
PrismaExporterWithConfig::render_schema(&all_tables)에서 스키마 전체를 한 번에
조립합니다. Orm::Prisma는 인터페이스 일관성 + 분기 안 자체 clean 호출을 위해
clean_export_dir / build_output_pathprisma 확장자로 함께 등록만 해
두었습니다.

테스트 설계

명세는 crates/vespertide-exporter/src/prisma/TESTING.md에 있습니다. 3-layer 구조:

  • Layer 1 — FK 없는 단일 엔티티 렌더링. render_entity(table)로 검증.
  • Layer 2 — schema 컨텍스트가 필요한 관계 포함 케이스.
    render_entity_with_schema(table, schema)로 검증. TableConstraint::ForeignKey
    하나라도 있는 TableDef는 무조건 Layer 2.
  • Layer 3 — edge case (예약어, 특수문자).

설계 규칙:

  • 모든 출력 검증은 insta::assert_snapshot! 사용. assert!(result.contains(...))
    방식은 금지 — 회귀 감지 정확도 확보 및 출력 전체를 고정.
  • 구조가 동일한 케이스(컬럼 타입 × nullable, default 값 변형, on_delete/on_update,
    singularize 등)는 #[rstest] 파라미터화로 작성.
  • 반복되는 ColumnDef 생성은 로컬 헬퍼(col, col_null, col_with_default,
    col_with_comment, pk, uniq, idx, fk, table, render_schema_all)로
    분리하여 케이스 본문을 짧게 유지.

테스트 65개 / 스냅샷 54개가 커버하는 범위:

Layer 항목
1-1 컬럼 타입 × nullable 25개 타입 (Simple + Complex, enum 포함) × {nullable=true, nullable=false}
1-2 PK 단일 autoinc / 단일 no-autoinc / composite / 없음
1-3 unique 단일 named / 단일 unnamed / composite named / composite unnamed
1-4 index 단일 named / 단일 unnamed / composite
1-5 default bool, now(), CURRENT_TIMESTAMP, gen_random_uuid(), uuid_generate_v4(), 임의 함수, 문자열 리터럴, 정수 리터럴, fallback 키워드
1-6 enum screaming_snake match, mapped, integer, nullable, enum default, 다중 컬럼 dedup
1-7 description / comment 있음 / 없음 / multiline / 컬럼 comment multiline
1-8 @@Map 항상 출력
2 관계 has-many, has-one (unique FK), nullable FK, 같은 테이블로의 다중 FK, 자기참조, on_delete/on_update (6 action), composite FK 무시, 복수형→단수형 back-relation (posts/categories/boxes/users)
3 edge 예약어 테이블명 (select, order, model, ...), 예약어 컬럼명 (default, unique, ...), description 안의 newline/quote/brace, default 값 안의 quote

이 PR에 대해 (원본 #151 이어받음)

원본 PR은 #151, @L33gn21 님이 작성하셨습니다. 다만 해당 fork에 제가 쓰기 권한이 없고, 작성자분이 미국에 계셔서 시차 때문에 실시간으로 논의하기 어려운 상황이라, 같은 브랜치를 제 fork(yyuneu/vespertide)로 이어받아 새 PR로 올립니다. 원본 커밋 4개는 그대로 두었고, 리뷰 반영 커밋 3개만 위에 추가했습니다.

PrismaConfig 리뷰 피드백 반영

@owjs3901 님이 남겨주신 코멘트:

clientOutput 옵션의 경우 이미 modelsDir이 존재하는 것으로 보이며 겹치는 것 같습니다.

provider 옵션의 경우 기본적으로 model만 관리하는 vespertide의 측면에서 부적절하다고
느낍니다.

relationMode의 경우 enum이어야 할 것 같습니다. 또한 관련 설명에 대해서 조금 더
자세한 설명을 부탁드립니다.

세 가지 다 맞는 지적이라 생각해서 그대로 반영했습니다.

  • clientOutput: prisma generate가 Client 라이브러리를 어디에 생성할지 정하는 값이라 modelsDir(모델 소스 위치)이랑 정확히 같은 개념은 아니지만, vespertide의 책임 범위(스키마/모델 정의) 밖이라는 말씀이 맞다고 판단해 필드를 제거했습니다.

  • provider: 지금 exporter는 PostgreSQL 네이티브 타입(@db.Uuid 등)만 생성하는데, config로 mysql/sqlite를 선택할 수 있게 열어두면 실제로는 깨진 스키마가 나오면서도 마치 지원하는 것처럼 보이는 문제가 있었습니다. 그래서 옵션은 제거하고 provider = "postgresql"을 고정값으로 넣었습니다. (자세한 이유는 아래 참고 부탁드립니다.)

  • relationMode: RelationMode enum(ForeignKeys/Prisma)을 추가했습니다. foreignKeys는 실제 DB FK 제약을 쓰는 기본값이고, prisma는 PlanetScale처럼 FK를 지원하지 않는 DB에서 Prisma Client가 대신 참조무결성을 챙겨주는 모드입니다.

이렇게 정리되면서 PrismaConfig는 필드가 relation_mode 하나만 남았습니다. schemas/config.schema.json도 다시 생성해서 반영했습니다.


참고: provider를 postgres 전용으로 남겨둔 이유

  • Prisma는 다른 4개 ORM(SeaORM 등)과 달리, 스키마 파일 자체에 DB 종류를 못박아야 하는 구조라(datasource.provider), 코드 생성 시점에 dialect를 미리 알아야 합니다.

  • MySQL: 네이티브 타입 매핑만 추가하면 되는 작업이라 나중에 추가가 가능하다 생각합니다.

  • SQLite: vespertide 모델에서 컬럼 타입을 enum으로 정의합니다. 하지만 Prisma의 sqlite 커넥터가 enum 자체를 공식 문서에서 지원하지 않아서, 단순 타입 매핑으로는 안 되고 enum을 문자열로 강등하는 등 별도 설계가 필요합니다.

  • 원본 PR도 처음부터 postgres 위주로 만들어져 있었고, 이번 리뷰에서도 멀티 dialect 자체는 별도 지적이 없으셔서, 그 방향 그대로 이번 PR도 postgres 전용으로 유지하고 mysql/sqlite는 후속 작업으로 남깁니다.

추가로 확인해서 함께 반영한 부분: 스냅샷 4건

변경 사항을 검증하면서 exporter 테스트 전체를 돌려봤는데, 이번 변경과는 무관한 기존 실패 4건을 발견했습니다. 확인해보니 원본의 dcbff76 커밋(제약명 name:map:, 정수 enum 기본값 처리, 숫자로 시작하는 식별자 처리)에서 코드 수정 자체는 정확히 반영되어 있었는데, 그에 맞춰 갱신됐어야 할 스냅샷 4개가 아직 반영되어 있지 않았던 것으로 보였습니다. 테스트 코드에 정의된 값과 비교해서 현재 코드 출력이 의도한 대로 맞다는 것도 확인했고, 코드 변경 없이 스냅샷만 최신 상태로 갱신했습니다.

Test plan

  • cargo build -p vespertide-config -p vespertide-exporter -p vespertide-cli
  • cargo test -p vespertide-config -p vespertide-exporter -p vespertide-cli — 전부 통과
  • cargo test -p vespertide-exporter — 567 passed, 0 failed
  • cargo run -p vespertide-schema-gen -- --out schemas 재생성 후 diff 확인 —
    PrismaConfig 관련 의도된 변경만 존재
  • 별도 테스트 프로젝트에서 vespertide export --orm prisma 실행해서 실제
    schema.prisma 확인 (provider 고정, clientOutput/relationMode 줄 없음,
    @@Map 유지)
  • 리뷰어: postgres 전용으로 유지하는 방향 + mysql/sqlite 후속 작업 계획
    괜찮으실지 확인 부탁드립니다.

L33gn21 and others added 7 commits June 23, 2026 00:01
Add a Prisma schema generator as a fourth ORM backend alongside SeaORM,
SQLAlchemy, and SQLModel. Renders enum + model blocks with full schema
context (relations, indexes, unique/composite constraints, native types,
default attributes, referential actions) and wires Prisma into the
cross-ORM test harness and CLI export command.

Model names use plural PascalCase; string default values escape
backslashes correctly. Snapshots cover the full column-type matrix plus
relation, enum, default, and reserved-identifier scenarios.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Generate the 60 missing Prisma insta snapshots so the Prisma backend is
cross-compared with the other four ORMs through the shared orm_cases!
matrix. The Orm::Prisma cases were already wired into every test; only
the accepted snapshot files were absent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirrors the sqlmodel/sqlalchemy 4-file convention. mod.rs is now a thin
orchestrator; render.rs holds model rendering and relation logic; types.rs
holds column-type mapping; enums.rs holds enum rendering and naming helpers.

Public API surface (PrismaExporter, PrismaExporterWithConfig, export,
render_entity, render_entity_with_schema, to_pascal_case_for_tests) unchanged.
All 565 tests pass with byte-identical snapshot output.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…, relation field naming

- @@unique/@@index DB constraint names now emit `map:` instead of `name:`
  (`name:` on @@index is invalid in Prisma 2.30+; on @@unique it sets the
  Prisma Client accessor, not the DB constraint name)
- integer-backed enum @default(...) now resolves to a variant identifier via
  numeric-value or variant-name match (e.g. @default(COMPLETED)), falling back
  to dbgenerated() when unmatched, instead of emitting an invalid bare int
- inline FK relation field gets a `_rel` suffix when stripping `_id` would
  collide with the scalar column; self-referential back-relation is emitted
- regenerate affected Prisma snapshots

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@owjs3901

owjs3901 commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

정말 애매하네요 이게... 모든 provider를 동시에 지원할 수 없다는게 정말 마음이 아픕니다

@owjs3901

owjs3901 commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

@yyuneu 다만 현재 mysql, postgres, sqlite 만 지원하니 PlanetScale등 fk를 지원하지 않는 DB는 신경쓰지 않아도 되므로 관련 config은 폐기함이 맞다고 생각합니다

그리고 각 provider 별 타입이 일치하지 않는게 무엇이 있는지 확인이 필요합니다, 현재 vespertide가 지원하는 타입의 경우 각 DB에서 딱히 지원하지 않는 타입이 없어모여 여쭙습니다

@yyuneu

yyuneu commented Jul 9, 2026

Copy link
Copy Markdown
Author

@owjs3901 피드백 감사합니다.
코드에 대해서 추가적인 확인을 진행했습니다. 말씀하신 대로 vespertide가 지원하는 타입 중에 세 DB에서 안 되는 타입은 없는 게 맞았습니다. 이 부분은 제가 코드 파악이 부족했습니다.

다시 보니 문제는 타입이 아니라 exporter가 붙이는 @db.* 네이티브 속성이었습니다. schema.prisma는 provider를 하나 선언해야 하고 @db.*가 그 provider 기준으로 검증되는데, 지금은 postgresql 고정 + PG 계열 속성을 항상 붙이고 있어서 MySQL/SQLite에서는 validate부터 안 될 것 같다고 생각했고, 이전에 provider config 이야기가 나왔던 것도 이 때문이었습니다.

실제로 지원 타입 전체를 담은 모델을 export해서 provider만 바꿔가며 prisma validate를 돌려본 결과입니다.

provider 결과
postgresql (현재 고정값) 에러 3개 — @db.Interval, @db.Cidr, @db.Macaddr
mysql 에러 8개 — 위 3개 + @db.Uuid, @db.Timestamptz, @db.Inet, @db.Xml, @db.Real
sqlite 에러 13개 — @db.* 전부 거부

@db.Interval/@db.Cidr/@db.Macaddr는 Prisma에 아예 없는 속성이라 지금 postgresql에서도 깨지는 현상이 발견되었습니다. 초기 구현부터 있던 매핑 오류인데 스냅샷 테스트가 텍스트 비교만 해서 걸러지지 않았습니다.

속성이 유효해도 어긋나는 경우가 있습니다. vespertide 자체가 MySQL에서는 uuidbinary(16), timestamptztimestamp로 DDL을 만들기 때문에, 스키마가 @db.Uuid/@db.Timestamptz라고 선언하면 실제 DB와 달라져 prisma db pull에서 드리프트가 생깁니다.

그래서 이런 방향으로 다시 하겠습니다. config에 DB 정보를 넣지 않는 원칙은 유지하고, sql/log가 이미 쓰는 --backend/-b 플래그를 export --orm prisma에도 받아서 백엔드별로 맞는 @db.*를 출력하도록 바꾸겠습니다. 매핑은 vespertide-query의 백엔드별 타입 매핑을 그대로 따르고(mysql이면 @db.Timestamp, PG 전용 타입은 속성 생략, sqlite면 전부 생략), 기본값은 기존 명령들과의 일관성을 위해 postgres로 두겠습니다.

그리고 이번에 -b 대응을 하면 datasource 생성부를 어차피 바꿀 수밖에 없어서, 이때 url = env("DATABASE_URL") 줄을 빼고 provider만 선언하도록 같이 바꾸겠습니다. 최신 Prisma는 연결 정보를 스키마 파일에 넣지 않는 방식이고, vespertide가 모델만 관리하고 DB 연결은 관리하지 않는 방향과도 맞다 생각합니다.

@yyuneu

yyuneu commented Jul 9, 2026

Copy link
Copy Markdown
Author

지난 코멘트에서 확인했던 provider별 타입 문제를 반영했고, CI 실패도 같이 정리했습니다.

존재하지 않는 네이티브 타입 제거

@db.Interval / @db.Cidr / @db.Macaddr는 Prisma에 아예 없는 속성이라, 현재 고정값인 postgresql에서도 validate가 깨지고 있었습니다. Prisma에 PG의 interval/cidr/macaddr에 대응하는 네이티브 타입이 없어서 plain String으로 내보내는 게 맞고, 그렇게 수정했습니다. 초기 구현부터 있던 매핑 오류인데 스냅샷 테스트가 텍스트 비교만 해서 걸러지지 않았습니다.

백엔드별 스키마 생성 (--backend/-b)

PR 본문에서 "mysql/sqlite는 후속 작업"으로 남겨뒀던 부분인데, 이번에 같이 해결했습니다.

config에 DB 정보를 넣지 않는 원칙은 유지하고, sql/log가 이미 쓰는 --backend/-b 플래그를 export에도 받습니다 (기본값 postgres). 백엔드별 @db.* 매핑은 vespertide-query가 백엔드별 DDL을 만들 때 쓰는 타입 강등 규칙을 그대로 따랐습니다.

  • mysql: timestamptz@db.Timestamp, inet/xml/interval@db.Text, uuid는 DDL이 binary(16)이므로 Bytes @db.Binary(16) + @default(dbgenerated("(uuid())"))
  • sqlite: connector가 네이티브 속성을 지원하지 않아 전부 생략

본문에서 걱정했던 sqlite enum은 확인해보니 최신 Prisma가 지원해서(런타임 검증 방식) 문자열 강등 같은 별도 설계 없이 그대로 나갈 수 있을 것 같습니다. 죄송합니다.

datasource에서 url 제거

-b 대응으로 datasource 생성부를 바꾸면서 같이 반영했습니다. 연결 정보를 스키마 파일에 넣지 않는 방식으로 변경 했습니다.

CI 실패 대응

  • fmt — 이전 커밋에서 cargo fmt이 누락된 파일 2개를 정리했습니다.
  • cargo-semver-checks — changepack 부재가 원인이었습니다. Orm enum에 variant가 추가되는 변경이라 exporter는 breaking bump가 필요해서 exporter/config/cli를 Minor로 추가했습니다. 버전 수준은 의견 있으시면 조정하겠습니다.
  • mutation-tests — 생존 뮤턴트 2개(VespertideConfig::prisma 접근자, cmd_export_prisma! 삭제)를 잡는 테스트를 추가했습니다.
  • coverage — prisma 쪽 미커버 라인(FK onUpdate, 명명된 @@index, 정수 enum 숫자 기본값 등)에 테스트를 채웠고, non_exhaustive enum의 도달 불가 와일드카드 arm 2곳은 기존 컨벤션대로 #[cfg(not(tarpaulin_include))] 처리했습니다.

검증

검증 방법 결과
생성 스키마 유효성 (수정 전) 지원 타입 전체 모델 export 후 provider별 prisma validate postgresql 에러 3, mysql 에러 8, sqlite 에러 13
생성 스키마 유효성 (수정 후) export --orm prisma -b postgres/mysql/sqlite × prisma validate 3개 모두 통과
url 제거 후 최신 Prisma 호환 최신 CLI로 3개 provider validate 3개 모두 통과
전체 테스트 cargo test --workspace --all-features 0 failed (신규 테스트 +30개)
기존 스냅샷 Prisma 스냅샷 60개 (기본 provider postgres 유지) diff 없음
뮤테이션 cargo mutants --in-diff (CI와 동일 스코프) 12개 중 missed 0
커버리지 CI와 동일 절차로 cargo tarpaulin --engine llvm 이 PR이 만든/수정한 파일 전부 100%
린트/포맷/라인 정책 clippy -D warnings / fmt / check-line-budget 통과

@yyuneu
yyuneu force-pushed the feat/prisma-exporter branch from 61790c8 to d4ea60b Compare July 11, 2026 15:13
@yyuneu

yyuneu commented Jul 11, 2026

Copy link
Copy Markdown
Author

포크에서 동일 워크플로우로 CI를 사전 검증했고, 실패 2건을 조치했습니다.

  • cargo-deny: 최근 공개된 RustSec 권고 2건(RUSTSEC-2026-0190 anyhow, RUSTSEC-2026-0204 crossbeam-epoch)으로 실패. Cargo.lock 잠금 갱신(1.0.103 / 0.9.20)으로 해결했습니다.
  • coverage: 미커버 1줄 — ERD 관계 수집에서 존재하지 않는 테이블을 참조하는 테이블 레벨 FK를 건너뛰는 분기에 테스트를 추가해 100%를 복구했습니다.

로컬에서 cargo deny / clippy / 전체 테스트(4076 passed) 통과를 확인했습니다.

@owjs3901
owjs3901 self-requested a review July 18, 2026 06:51
/// Derive `crate::` prefix from the export directory path.
///
/// For example: `src/models` `crate::models`, `src/db/entities` `crate::db::entities`.
/// For example: `src/models` ??`crate::models`, `src/db/entities` ??`crate::db::entities`.

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
Author

Choose a reason for hiding this comment

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

main 원문으로 복원했습니다.

Comment on lines +220 to +221
/// For example: `admin/admin.json` ??`["admin", "admin"]`
/// `estimate/estimate_checker.vespertide.json` ??`["estimate", "estimate_checker"]`

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
Author

Choose a reason for hiding this comment

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

main 원문으로 복원했습니다.

Comment on lines +45 to +51
fn prisma_provider_for_backend(backend: DatabaseBackend) -> PrismaProvider {
match backend {
DatabaseBackend::Postgres => PrismaProvider::Postgres,
DatabaseBackend::MySql => PrismaProvider::MySql,
DatabaseBackend::Sqlite => PrismaProvider::Sqlite,
}
}

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.

PrismaProvider 구조체를 이용하지 않아도 될 것 같습니다

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

enum을 제거하는 방향으로 갔기 때문에 From trait도 함께 사라졌습니다.

Comment on lines +674 to +730
#[rstest]
#[case::postgres(
DatabaseBackend::Postgres,
"provider = \"postgresql\"",
"@db.Timestamptz",
None
)]
#[case::mysql(
DatabaseBackend::MySql,
"provider = \"mysql\"",
"@db.Timestamp",
Some("@db.Timestamptz")
)]
#[case::sqlite(
DatabaseBackend::Sqlite,
"provider = \"sqlite\"",
"DateTime",
Some("@db.")
)]
#[tokio::test]
#[serial]
async fn export_prisma_writes_provider_specific_schema(
#[case] backend: DatabaseBackend,
#[case] provider_line: &str,
#[case] expected: &str,
#[case] absent: Option<&str>,
) {
let tmp = tempdir().unwrap();
let _guard = CwdGuard::new(&tmp.path().to_path_buf());
write_config();

let mut model = sample_table("events");
model.columns.push(ColumnDef {
name: "occurred_at".into(),
r#type: ColumnType::Simple(SimpleColumnType::Timestamptz),
nullable: false,
default: None,
comment: None,
primary_key: None,
unique: None,
index: None,
foreign_key: None,
});
write_model(Path::new("models/events.json"), &model);

cmd_export(OrmArg::Prisma, None, backend).await.unwrap();

let out = PathBuf::from("src/models/schema.prisma");
assert!(out.exists());
let content = std_fs::read_to_string(out).unwrap();
assert!(content.contains(provider_line));
assert!(content.contains(expected));
if let Some(absent) = absent {
assert!(!content.contains(absent));
}
}

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.

prisma를 위한 테스트이므로 해당 파일에 존재하는 것은 부적절해보입니다

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Prisma 전용 테스트를 export/tests/prisma.rs로 분리했습니다. export.rsexport/{mod.rs, tests/mod.rs, tests/prisma.rs}로 나뉘었습니다.

#[case(OrmArg::Sqlmodel, Orm::SqlModel)]
#[case(OrmArg::Jpa, Orm::Jpa)]
#[case(OrmArg::Prisma, Orm::Prisma)]
fn orm_arg_maps_to_enum(#[case] arg: OrmArg, #[case] expected: Orm) {

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
Author

Choose a reason for hiding this comment

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

감사합니다.

Comment thread crates/vespertide-config/src/config.rs Outdated
Comment on lines +130 to +132
/// Prisma-specific export configuration.
#[serde(default)]
pub prisma: PrismaConfig,

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
Author

Choose a reason for hiding this comment

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

확인 감사합니다. 이후 PrismaConfig 자체를 제거해서 이 부분도 함께 사라졌고, vespertide-config 크레이트는 main과 diff가 0입니다.

Comment on lines +15 to +18
/// Emulate referential integrity in Prisma Client instead of relying on
/// database-level foreign keys. Needed for datastores that don't support
/// FK constraints (e.g. PlanetScale, MongoDB).
Prisma,

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.

prisma를 위한 옵션을 제거하는 것이 옳을 것 같습니다
mongoDB를 지원하고 있지 않으므로 관련 옵션 제거가 필요해보입니다

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

RelationModePrismaConfig를 통째로 제거했습니다. relationMode가 유일한 필드였어서 빈 구조체를 남길 이유가 없었고, vespertide-config 크레이트와 config.schema.json은 main과 동일해졌습니다.

Comment on lines +21 to +26
pub enum PrismaProvider {
#[default]
Postgres,
MySql,
Sqlite,
}

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.

기존 db enum으로 대체 가능

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

DatabaseBackend로 교체했다가, 최종적으로 백엔드 분기 자체를 없애면서 enum이 통째로 제거됐습니다.

Comment on lines +28 to +37
impl PrismaProvider {
/// The `provider = "..."` string used in the `datasource` block.
pub fn as_datasource_str(self) -> &'static str {
match self {
PrismaProvider::Postgres => "postgresql",
PrismaProvider::MySql => "mysql",
PrismaProvider::Sqlite => "sqlite",
}
}
}

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.

From trait을 사용하는 것이 옳음 다만 enum 자체를 제거할 것이라서..

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

DatabaseBackend로 교체했다가, 최종적으로 백엔드 분기 자체를 없애면서 enum이 통째로 제거됐습니다.

Comment on lines +177 to +193
#[rstest]
#[case::postgres(PrismaProvider::Postgres, "provider = \"postgresql\"")]
#[case::mysql(PrismaProvider::MySql, "provider = \"mysql\"")]
#[case::sqlite(PrismaProvider::Sqlite, "provider = \"sqlite\"")]
fn render_schema_emits_configured_provider(
#[case] provider: PrismaProvider,
#[case] expected_line: &str,
) {
let config = PrismaConfig::default();
let tables = vec![basic_single_pk()];
let schema =
PrismaExporterWithConfig::with_provider(&config, provider).render_schema(&tables);

assert!(schema.contains(expected_line));
assert!(!schema.contains("output"));
assert!(!schema.contains("relationMode"));
}

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.

provider에 따른 분리를 제거함이 옳음

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

provider에 따른 분리를 전부 제거했습니다. 현재는 @db.* 네이티브 속성을 하나도 내보내지 않고 datasource 블록도 없어서, 출력이 백엔드와 무관하게 동일합니다.

@yyuneu
yyuneu force-pushed the feat/prisma-exporter branch from b9c961a to 8751b39 Compare July 26, 2026 10:44
@yyuneu

yyuneu commented Jul 26, 2026

Copy link
Copy Markdown
Author

리뷰 감사합니다. 9개 코멘트를 모두 확인했습니다. 4건은 코드에 반영했고, 나머지는 확인 결과와 근거를 아래에 정리했습니다. 두 건은 이 PR에 넣을지 여쭙고 싶습니다.

PascalCase 유틸 중복

지적대로 vespertide_naming::to_pascal_case가 이미 있었고 exporter는 이 크레이트를 의존성에 두고도 쓰지 않고 있었습니다. Prisma의 자체 구현을 지우고 공용 함수를 쓰도록 바꿨습니다. 픽스처의 테이블명·enum명이 모두 [a-z0-9_]라 스냅샷 출력은 변하지 않습니다.

부수적으로, Prisma가 자체 구현을 갖지 않게 되면서 테스트 전용 접근자(to_pascal_case_for_tests)도 노출할 대상이 사라져 함께 삭제했습니다. 교차 ORM 테스트는 공용 함수를 직접 호출합니다.

SCREAMING_SNAKE 유틸 중복

동명 유틸이 sqlalchemy/render.rssqlmodel/enums.rs에 있는 것을 확인했습니다(두 곳이 서로 복붙된 동일 구현). 다만 그대로 재사용할 수는 없었습니다. 기존 구현은 단어 경계를 직전 글자가 소문자인지가 아니라 위치가 0보다 큰지로 판정해서, 이미 대문자인 입력을 글자마다 분해합니다. 후행 구분자 정리와 선행 숫자 가드도 없습니다.

입력 기존 구현 Prisma 구현
pending / inProgress / order-status 동일 동일
ERROR_LEVEL E_R_R_O_R__L_E_V_E_L ERROR_LEVEL
HTTP_500 H_T_T_P__500 HTTP_500
1critical 1CRITICAL _1CRITICAL

이는 저장소의 기존 스냅샷에 이미 나와 있는 동작입니다. enum_special_values 픽스처가 정확히 이 입력을 담고 있고, SQLAlchemy/SQLModel 스냅샷은 아래를 출력하고 있었습니다.

class EventSeverity(str, enum.Enum):
    E_R_R_O_R__L_E_V_E_L = "ERROR_LEVEL"
    1CRITICAL = "1critical"

1CRITICAL은 식별자가 숫자로 시작해 Python SyntaxError입니다. 즉 이 픽스처에 해당하는 생성 파일은 통째로 import가 불가능한 상태였습니다. 실행해서 확인했습니다.

OLD -> SyntaxError: invalid decimal literal
NEW -> OK, members: ['INFO_LEVEL', 'ERROR_LEVEL', '_1CRITICAL']

그래서 이번 PR에서는 Prisma 자체 구현을 그대로 두었습니다.

한 번 통합해봤는데, Prisma 구현을 정본으로 vespertide-naming에 올리고 세 백엔드가 공유하는 형태였습니다. 동작하고 CI도 통과했지만 되돌렸습니다. SQLAlchemy·SQLModel의 생성 출력이 바뀌고(위 버그가 고쳐지는 대신, 이미 대문자인 enum 값을 쓰던 사용자는 멤버명이 달라집니다) vespertide-naming의 공개 API가 늘어나 changepack에 크레이트가 하나 더 들어가는데, Prisma exporter를 추가하는 PR이 감당할 범위는 아니라고 판단했습니다.

커버리지 제외

#[cfg(not(tarpaulin_include))] 두 곳을 제거하고, JPA·SQLAlchemy·SQLModel이 같은 상황에서 쓰고 있는 방식으로 맞췄습니다.

_ => unreachable!("SimpleColumnType is #[non_exhaustive]; all variants are matched above"),

#[non_exhaustive] 열거형이라 와일드카드 arm 자체는 제거할 수 없지만, Prisma만 조용한 String 폴백 + 커버리지 제외로 저장소 관례에서 이탈해 있었습니다. 교체 후 prisma/types.rs 커버리지는 100%입니다.

테스트 유틸리티 위치

위에서 적은 대로 해당 함수 자체가 없어졌습니다.

파일명 정규화

이 줄은 이 PR에서 작성한 코드가 아니라 main에 이미 있던 테스트입니다. mainexport.rs:698에 동일한 케이스가 있고, 대상 함수 sanitize_filename도 main과 바이트 단위로 같습니다.

diff에 새 코드로 표시된 이유는 이렇습니다. 앞선 리뷰에서 Prisma 테스트를 별도 파일로 분리해달라고 하셔서 export.rs(992줄)를 셋으로 쪼갰는데,

export.rs  →  export/mod.rs (506)
              export/tests/mod.rs (576)
              export/tests/prisma.rs (55)

git이 1→3 분할을 rename으로 인식하지 못해 delete export.rs + create 세 건으로 기록합니다. 그래서 export/tests/mod.rs가 100% 새 파일로 표시되고 576줄 전부가 추가 줄이 되었습니다.

그래서 이번 PR에서는 수정하지 않았습니다. sanitize_filename은 모든 ORM의 출력 파일명에 쓰이고 SeaORM에서는 모듈 경로 세그먼트 생성에도 쓰여서, 동작을 바꾸면 Prisma 밖까지 영향이 가게 되어 이번 pr 범위를 벗어난다 생각 했습니다.

datasource provider

datasource 블록을 선언하지 않는 형태를 검토 했습니다.

datasource를 선언한다면 그 안의 provider는 필수이고 정적 문자열만 받는 게 맞습니다. 하지만 블록 자체를 넣지 않으면 선언 할 필요가 없습니다. 모델과 enum만 담긴 파일은 그 자체로 유효합니다. 그래서 datasourcegenerator 블록을 출력에서 제거했습니다. 이제 vespertide는 다른 네 백엔드와 똑같이 모델만 생성하고, 프로젝트 설정은 사용자가 소유합니다.

사용자 쪽 datasource와 결합하면 prisma generate까지 정상 동작합니다(sqlite로 Client 생성 확인). Prisma의 멀티파일 스키마는 하위 디렉터리까지 재귀적으로 읽으므로, 스키마 루트에 사용자 파일을 두고 vespertide는 하위로 내보내는 배치가 가능합니다.

출력 파일명은 schema.prismamodels.prisma로 바꿨습니다. 앞의 이름은 datasource를 담는 사용자 파일 몫입니다. export 디렉터리는 다른 백엔드와 마찬가지로 vespertide가 정리하는 영역이므로, 사용자 파일은 그 상위에 두면 됩니다.

Decimal 기본값

모델에 선언된 default 문자열을 그대로 통과시킨 결과이고, 5개 ORM이 모두 같습니다.

JPA         private BigDecimal price = 0.00;
SeaORM      #[sea_orm(default_value = 0.00)]
SQLAlchemy  server_default="0.00"
SQLModel    Field(default=0.00)
Prisma      @default(0.00)

Prisma만 0으로 줄이면 사용자가 scale = 2로 선언한 의도가 출력에서 사라지고 백엔드 간 표현도 어긋나서, 원문 보존이 맞다고 판단했습니다.

uuid 주석

제거했습니다.


검증은 로컬에서 CI와 같은 항목을 모두 돌렸습니다. 또한 레포 ci를 이용하여 100% 통과를 확인 했습니다.

@yyuneu
yyuneu requested a review from owjs3901 July 26, 2026 12:11

@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.

코멘트 단 것에 대한 답변 회신이 없습니다, 모든 코멘트에 답신이 와야 합니다

@yyuneu

yyuneu commented Jul 26, 2026

Copy link
Copy Markdown
Author

코멘트 단 것에 대한 답변 회신이 없습니다, 모든 코멘트에 답신이 와야 합니다

@owjs3901 전부 회신 했습니다. 감사합니다.

@yyuneu
yyuneu requested a review from owjs3901 July 26, 2026 14:15
.collect()
}

pub(super) fn to_screaming_snake(s: &str) -> String {

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.

기존의 함수명과 역할이 같은 만큼 기존 것을 개선하는 방향으로 가는 것이 옳습니다, 다만 실험하신 방향도 맞다고 생각됩니다

최종적으로는 숫자로 table 등을 시작할 수 없으니 이 경우 선재적으로 blocking을 하고 해당 함수는 유틸리티로 공통함수로 분리하는 것이 옳다고 생각됩니다

같은 역할의 동명의 함수를 추가할 수는 없습니다

---
model Products {
id Int
price Decimal @default(0.00)

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.

동의합니다, 의도에 맞춘 default 값, 좋은 인사이트입니다

---
model Products {
id Int
price Decimal @default(0.00)

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.

다만 scale = 2로 설정을 하는 옵션을 별도로 만드는 것이 어떨까합니다
추가적으로 큰 문제가 없고 scale 2 옵션을 넣을 수가 없다면 모든 export가 0으로 default value가 정의되어야 한다고 생각합니다

#[case::sqlalchemy(Orm::SqlAlchemy)]
#[case::sqlmodel(Orm::SqlModel)]
#[case::jpa(Orm::Jpa)]
#[case::prisma(Orm::Prisma)]

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.

Good

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

감사합니다.

@yyuneu
yyuneu requested a review from owjs3901 July 29, 2026 09:26
Comment thread crates/vespertide-naming/src/lib.rs Outdated
Comment on lines +513 to +528
#[test]
fn test_to_screaming_snake_case() {
assert_eq!(to_screaming_snake_case("pending"), "PENDING");
assert_eq!(to_screaming_snake_case("not_started"), "NOT_STARTED");
assert_eq!(to_screaming_snake_case("inProgress"), "IN_PROGRESS");
assert_eq!(to_screaming_snake_case("order-status"), "ORDER_STATUS");
assert_eq!(to_screaming_snake_case(""), "");
// Already-uppercase input survives: a position-based word-boundary rule
// would explode these into `E_R_R_O_R__L_E_V_E_L` / `H_T_T_P__500`.
assert_eq!(to_screaming_snake_case("ERROR_LEVEL"), "ERROR_LEVEL");
assert_eq!(to_screaming_snake_case("HTTP_500"), "HTTP_500");
// Trailing separators are trimmed and a leading digit is prefixed, so
// the result stays a valid identifier.
assert_eq!(to_screaming_snake_case("status-"), "STATUS");
assert_eq!(to_screaming_snake_case("1critical"), "_1CRITICAL");
}

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.

rstest를 사용합시다

@yyuneu yyuneu Aug 2, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

반영했습니다.

지적하신 테스트를 rstest 케이스로 바꿨고, 같은 기준을 이 브랜치에서 작성한 테스트 전체에 적용했습니다. 입력이 2개 이상인 테스트는 #[case::name(...)]으로 쓰고, 단일 입력만 plain #[test]로 남겼습니다.

익스포터 스냅샷은 개별 #[test]를 만들지 않고 orm_cases! 매크로로만 추가했습니다. 시나리오 하나를 넣으면 5개 ORM 스냅샷이 한 번에 생성되므로 백엔드별 출력이 항상 교차 비교됩니다. 이번에 추가한 시나리오 2개도 같은 방식이라 스냅샷 10장이 함께 생성됩니다.

@yyuneu

yyuneu commented Aug 2, 2026

Copy link
Copy Markdown
Author

리뷰에서 지적된 항목과 그 과정에서 드러난 식별자 문제를 정리했습니다.

##risma 외의 백엔드까지 바뀐 이유

이번 커밋이 pr범위에 조금 벗어난다 생각해 추가로 이유를 적습니다. SeaORM·JPA·SQLAlchemy·SQLModel·CLI까지 바뀌어 있어서, 그 경위를 먼저 적습니다.

  1. 리뷰에서 "선두 숫자 같은 이름은 익스포터에서 정규화하자"는 지적을 받았습니다.
  2. 어디까지 문제인지 실측해보니 Prisma만의 문제가 아니었습니다. main 바이너리로 돌려보면 1users 테이블 하나로 SeaORM은 컴파일 실패, JPA는 javac 실패, SQLModel은 Pydantic이 필드를 거부합니다.
  3. 그런데 백엔드마다 받아주는 시작 문자가 다릅니다. Prisma·Pydantic은 선두 _를 거부하고, Rust 모듈·Java·SQLAlchemy는 허용합니다. Prisma만 고치면 같은 규칙의 구현이 백엔드마다 하나씩 생깁니다. 리뷰에서 이미 중복 함수를 지적받았고, 익스포터 스냅샷 정책 자체가 "한 시나리오를 모든 ORM으로 교차 비교한다"이므로, 규칙을 vespertide-naming 한 곳에 두고 백엔드는 시작 문자만 고르는 형태로 맞췄습니다.

그래서 실제로 바뀐 범위는 이렇습니다.

기존 스냅샷 중 내용이 바뀐 것 6장
새로 생긴 스냅샷 80장 (기존 시나리오 60개에 Prisma 출력이 붙어 60장 + 새 시나리오 4개 × 5 ORM = 20장)
삭제된 스냅샷 0장

바뀐 6장은 이게 전부입니다.

enum_special_values  SqlAlchemy/SqlModel   E_R_R_O_R__L_E_V_E_L → ERROR_LEVEL
                                           1CRITICAL            → _1CRITICAL
numeric_default_value  4개 백엔드           default_value = 0.00 → 0

앞의 것은 SCREAMING_SNAKE 값이 글자마다 쪼개지던 버그, 뒤의 것은 정수 컬럼 픽스처가 소수 리터럴을 쓰고 있던 것을 고친 결과입니다. 정상적인 이름을 쓰는 기존 사용자 출력은 한 글자도 바뀌지 않습니다 이름이 바뀌지 않으면 매핑 속성을 추가하지 않도록 했기 때문입니다.

Prisma 익스포터

--orm prisma로 모델을 Prisma 스키마로 내보냅니다.

  • 모델 블록만 내보내고 datasource / generator는 내보내지 않습니다. 두 블록은 스키마가 아니라 사용자 프로젝트 설정이고, provider를 박으면 출력이 특정 백엔드 전용이 됩니다. Prisma의 멀티 파일 스키마 디렉터리에서 사용자 파일과 함께 두면 됩니다. 다른 백엔드가 모델만 내보내는 것과 같은 형태입니다.
  • 같은 이름의 enum이 서로 다른 값을 가지면 {Table}{Enum} 형태로 분리합니다. SQL 계층이 {table}_{enum} 타입으로 구분하는 것과 같은 기준입니다. 이름이 PascalCase 변환 후에 겹치는 경우도 있어서 판단은 변환 후 이름으로 합니다.
  • 관계 필드 이름이 나중에 오는 컬럼의 이름을 가져가지 않도록 컬럼 이름을 먼저 선점합니다.

식별자 정규화

SQL은 따옴표로 감싸면 어떤 이름이든 받지만 대상 언어는 그렇지 않습니다. 1users 테이블, 1st_place 컬럼, user-id 컬럼 같은 이름이 그대로 나가서 생성된 코드가 컴파일되지 않았습니다.

언어별 시작 문자 규칙을 vespertide-naming의 한 헬퍼로 모으고, 이름을 바꾼 자리에는 항상 원래 DB 이름을 되돌리는 매핑을 함께 내보내도록 했습니다.

대상 선두 문자 원래 이름을 되돌리는 수단
Prisma 글자 (선두 _ 거부) @map / @@map
SQLModel (Pydantic) 글자 (선두 _ 거부) sa_column_kwargs={"name": ...}
SeaORM 글자 (derive가 _를 떨어뜨림) #[sea_orm(column_name = ...)]
SQLAlchemy _ 허용 mapped_column("db_name", ...) 위치 인자
JPA _ 허용 @Table(name=) / @Column(name=)

이름이 바뀌지 않으면 매핑을 추가하지 않으므로 기존 출력은 영향받지 않습니다.

관계에서 파생되는 이름

컬럼 이름만 고쳐서는 부족했습니다. 관계 이름은 별도 경로에서 생성되고 있었고 그쪽에는 escape가 없었습니다.

  • SeaORM 역방향 관계 필드 이름 — pub 1st_owner_posts: HasMany<...>로 나가 문법 오류였습니다.
  • SeaORM relation_enum / via_rel / from / to — sea-orm이 이 문자열들을 Rust 경로로 파싱한 뒤 PascalCase해서 Column 변형을 찾습니다. 즉 DB 컬럼 이름이 아니라 모델 필드 이름이 들어가야 합니다.
  • SeaORM 엔티티 모듈 이름 — sea-orm은 관계 필드의 Relation 변형을 대상 엔티티의 모듈 이름에서 역산합니다. _1usersPascalCase 과정에서 _가 떨어져 1users가 되고 derive가 panic합니다. 그래서 모듈 이름도 글자로 escape합니다. 익스포터가 쓰는 super::{module}::Entity와 CLI가 쓰는 pub mod {module};, 파일 이름이 모두 같아야 하므로 규칙은 한 곳에만 둡니다.
  • SeaORM enum 변형 이름 — to_pascal_case_-만 분리해서 값 안의 공백이 그대로 남았습니다 (in progressIn progress,).
  • SeaORM 관계 필드가 컬럼 필드와 같은 이름을 가져가 필드가 두 번 선언되던 문제 — Prisma에서 고친 것과 같은 문제라 같은 방식으로 맞췄습니다.
  • Prisma @relation(references: [...]) — 대상 모델의 필드 이름 자리인데 DB 컬럼 이름이 나가고 있었습니다.
  • Prisma @@id / @@unique / @@index — 같은 자리입니다. 모델 필드 이름을 받는데 DB 컬럼 이름이 나가서, escape가 필요한 컬럼이 복합 키나 인덱스에 들어가면 스키마가 파싱되지 않았습니다. 제약의 DB 이름은 옆의 map:이 그대로 담고 있습니다.

그 외 수정

  • unquote 헬퍼: 'say "hi"' 같은 리터럴에서 문자 단위 trim이 안쪽 따옴표까지 잘라먹던 것을 바깥 한 쌍만 벗기도록 통일했습니다 (Prisma / JPA / SQLModel 공용).
  • to_screaming_snake_case를 순수 케이스 변환으로 되돌리고 escape는 호출부에서 하도록 분리했습니다. 케이스 변환 함수가 식별자 규칙까지 겸하면 백엔드마다 다른 규칙을 표현할 수 없습니다.
  • ERD 커맨드에 있던 동일 구현 sanitize_identifier를 공용 함수 호출로 바꿨습니다.
  • AGENTS.md의 익스포터 스냅샷 정책이 "all four ORMs" / "exactly four snapshots"로 적혀 있었는데, 이 PR이 다섯 번째 백엔드를 추가하면서 사실과 달라져 함께 맞췄습니다.

검증

스냅샷은 출력이 "바뀌었는지"는 잡지만 "유효한지"는 잡지 않습니다. 그래서 생성물을 실제 도구에 넣어 확인했습니다.

입력: 식별자 케이스 8종 + examples/app 11모델 + 110테이블 생성 스키마

도구 결과
sea-orm 2.0 실제 컴파일 main 531 에러 → 이 브랜치 0
prisma validate v6 / v7 20/20 통과
python -m py_compile 130/130 통과
SQLModel 런타임 인스턴스화 필드 → 컬럼 매핑 확인 (x1st_place1st_place)
javac 실패 3건은 전부 기존 문제 (아래 이슈 참고, main에서 에러 개수까지 동일)

리포지토리 게이트: fmt / clippy 0, 테스트 통과, 스냅샷 드리프트 0, line budget 통과.

테스트

orm_cases!로 시나리오 3개를 추가했고 5개 ORM 스냅샷 15장이 함께 생성됩니다.

  • non_identifier_relation_names — 참조 테이블·PK·참조 컬럼이 전부 escape가 필요한 이름이고, 한 테이블로 FK가 두 개 갑니다. 관계 이름을 실제로 만들어내는 조건이라 escape 누락이 여기서 드러납니다.
  • non_identifier_names_in_constraints — 복합 PK·복합 unique·인덱스가 escape가 필요한 컬럼을 참조합니다. Prisma는 이 자리를 모델 필드 이름으로, 나머지 백엔드는 DB 컬럼 이름으로 적어야 해서 다섯 출력이 서로 달라야 합니다.
  • relation_name_taken_by_column — 관계 필드와 컬럼이 같은 이름을 두고 경합하는 경우입니다.

@yyuneu

yyuneu commented Aug 2, 2026

Copy link
Copy Markdown
Author

확인 과정에서 이번 PR 범위 밖의 문제도 나와 여기에 정리 합니다.

1. SQLAlchemy 출력은 import 자체가 되지 않습니다.

class X(DeclarativeBase) 형태로 나가는데 SQLAlchemy 2.x는 DeclarativeBase를 직접 상속한 매핑 클래스를 거부합니다. 특수한 이름과 무관하게 모든 테이블에 해당합니다.

$ vespertide export --orm sqlalchemy
$ python -c "import runpy; runpy.run_path('single.py')"
sqlalchemy.exc.InvalidRequestError: Cannot use 'DeclarativeBase' directly as a
declarative base class. Create a Base by creating a subclass of it.

2. JPA는 enum을 파일마다 top-level로 내보내서 같은 패키지 안에서 충돌합니다.

examples/app에서 재현됩니다. article_user.roleuser_media_role.role이 각각 enum Role을 선언 → error: duplicate class: Role.

3. JPA enum 컬럼의 기본값이 문자열로 대입됩니다.

= "draft" 형태로 나가 enum 필드에 대입되지 않습니다. error: incompatible types: String cannot be converted to Status. examples/app에서 2건입니다.

4. Python 출력 파일 이름은 정규화되지 않습니다.

모델 파일이 1users.json이면 1users.py가 생성되는데 이 이름으로는 import할 수 없습니다. SeaORM(모듈 이름)과 JPA(공개 클래스명 일치)는 파일 이름을 맞추지만 Python은 그대로 둡니다.

5. FK 컬럼 이름이 Rust 키워드면 SeaORM 출력이 컴파일되지 않습니다.

sea-orm이 from / to 문자열을 Rust 식별자로 파싱한 뒤 PascalCase해서 Column 변형을 찾는데, 두 표기 모두 실패합니다.

from = "match"    → error: expected identifier, found keyword `match`
from = "r#match"  → error: no variant named `RMatch` found (실제 변형은 `Match`)

main은 전자, 현재 브랜치는 후자입니다. 해결하려면 SeaORM 필드의 키워드 escape를 r# 대신 다른 방식으로 바꿔야 할 것 같은데, 기존 동작이라 임의로 바꾸지 않았습니다. (main에는 이 케이스에 필드 중복 선언 오류도 함께 있었고, 그건 이번에 고쳤습니다.)

6. 복합 FK가 Prisma와 JPA에서 조용히 사라집니다.

기존 table_with_composite_fk 픽스처(line_items[order_id, order_version]orders[id, version])의 5개 스냅샷을 비교하면 백엔드마다 다릅니다.

백엔드 복합 FK 출력
SeaORM #[sea_orm(belongs_to, from = "(order_id, order_version)", to = "(id, version)")] — 지원
SQLAlchemy ForeignKeyConstraint([...], [...]) — DB 제약은 보존 (단일 FK도 relationship()을 만들지 않으므로 설계상 일관)
SQLModel 동일
JPA 아무것도 없음 — 관계도 제약도 나오지 않습니다
Prisma 아무것도 없음 — 관계도 제약도 나오지 않습니다

두 백엔드 모두 표현 수단이 있습니다. Prisma는
@relation(fields: [a, b], references: [c, d]), JPA는 @JoinColumns({...}). examples/app에서 실제로 발생합니다 — article_user[media_id, article_id] → article[media_id, id]가 Prisma 출력에 없어서 articleUser.article을 쓸 수 없습니다.

Prisma 쪽 필터는 이어받은 PR #151의 0c6f27b에서 온 것이고 (prisma/render.rscolumns.len() == 1 4곳), JPA 쪽은 jpa/render.rs:167columns.len() == 1 && ref_columns.len() == 1입니다.

이번 커밋 범위가 넓고, 작성된 코드가 나와 prisma부분은 아직 고치지 않았습니다.

7. 익스포터 출력의 유효성을 검사하는 CI가 없습니다. ← 위 6건의 공통 원인

sql-validity 잡은 생성된 SQL을 SQLite 실제 실행 / sqlparser 3개 방언 / pg_query(실제 PostgreSQL C 파서)에 넣어 검사합니다. 익스포터에는 그에 해당하는 검사가 없습니다.

  • insta 스냅샷 315장은 생성된 텍스트가 이전과 같은지만 봅니다. 컴파일되지 않는 코드도 스냅샷에 기록되면 통과입니다.
  • 익스포터 통합 테스트 3개는 determinism / 병렬 일치성 검사라 유효성과 무관합니다.
  • .cargo/mutants.tomlexclude_globscrates/vespertide-exporter/**가 있어 뮤테이션 게이트도 닿지 않습니다.

그래서 위 1~7번이 CI 초록인 상태로 남아 있었습니다. sql-validity의 익스포터 판을 만드는 것을 제안합니다.

  • SeaORM → 실제 sea-orm 의존 크레이트에 넣어 cargo build
  • Prisma → prisma validate
  • SQLAlchemy / SQLModel → import 후 __table__.columns 매핑 확인
  • JPA → javac

같은 검사가 필요하다 생각합니다.

@yyuneu

yyuneu commented Aug 2, 2026

Copy link
Copy Markdown
Author

또한 escape 문자 하나만 판단을 여쭙니다.

Prisma와 SQLModel은 선두 _를 거부해서 글자로 escape합니다. 이때 접두 문자는 뒤에 오는 이름의 대소문자를 따라가므로 model x1users / class x1users / x1st_place처럼 나옵니다.

여기서 SeaORM만 다르게 동작합니다. relation_enum / via_rel / Linked 구조체 같은 타입성 이름은 첫 글자를 대문자로 강제해서 X1stOwner가 됩니다. 소문자로 시작하는 Rust enum 변형은 non_camel_case_types 린트에 걸리기 때문인데, Prisma PSL과 Python에는 대응하는 린트가 없어 강제하지 않았습니다.

지금은 Rust만 대문자 강제인데, 접두 문자를 다른 것으로 바꾸거나 모든 백엔드에서 대문자로 통일하는 편이 낫다면 맞추겠습니다.

@yyuneu
yyuneu requested a review from owjs3901 August 2, 2026 20:42
Comment on lines +268 to +269
pub(super) fn sanitize_identifier(input: &str) -> String {
let mut identifier = String::new();

for (index, ch) in input.chars().enumerate() {
if ch == '_' || ch.is_ascii_alphanumeric() {
if index == 0 && ch.is_ascii_digit() {
identifier.push('_');
}
identifier.push(ch);
} else {
identifier.push('_');
}
}

if identifier.is_empty() {
"_".to_string()
} else {
identifier
}
vespertide_naming::sanitize_identifier(input, IdentifierStart::Underscore)

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.

이런함수는 좋지 않아보입니다, 차라리 vespertide_naming에 있어야할 것 같아요

Comment on lines +110 to +127
#[rstest]
#[case::already_screaming(
vec!["DRAFT".into(), "PUBLISHED".into()],
"enum DocStatus {\n DRAFT\n PUBLISHED\n}"
)]
#[case::normalized(
vec!["draft".into(), "in progress".into()],
"enum DocStatus {\n DRAFT @map(\"draft\")\n IN_PROGRESS @map(\"in progress\")\n}"
)]
// `_1CRITICAL` would be rejected by Prisma's parser, so the escape is a
// letter; `@map` still carries the value the database stores.
#[case::leading_digit(
vec!["1critical".into()],
"enum DocStatus {\n X1CRITICAL @map(\"1critical\")\n}"
)]
fn string_variants_carry_map_only_when_normalization_changes_them(
#[case] values: Vec<String>,
#[case] expected: &str,

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.

Good

Comment on lines +59 to +61
pub fn render_entity(table: &TableDef) -> String {
render_entity_with_schema(table, std::slice::from_ref(table))
}

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.

이건 별로 안좋아보입니다

Comment on lines +99 to +103
assert!(schema.starts_with("model "));
assert!(!schema.contains("datasource"));
assert!(!schema.contains("generator"));
assert!(!schema.contains("provider"));
assert!(!schema.contains("@db."));

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을 찍는게 차라리 나을 것 같아요

Comment on lines +125 to +129
assert!(schema.contains("enum OrdersStatus {"));
assert!(schema.contains("enum TicketsStatus {"));
assert!(!schema.contains("enum Status {"));
assert!(schema.contains(" st OrdersStatus"));
assert!(schema.contains(" st TicketsStatus"));

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이 나을 것 같아요

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.

3 participants