Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changepacks/changepack_log_vKVGmtRxkfW3nE7rIhMcz.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"changes":{"crates/vespertide-exporter/Cargo.toml":"Minor","crates/vespertide-config/Cargo.toml":"Minor","crates/vespertide-cli/Cargo.toml":"Minor"},"note":"Prisma ORM exporter 추가","date":"2026-07-09T14:25:30.6412773Z"}
23 changes: 12 additions & 11 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

154 changes: 142 additions & 12 deletions crates/vespertide-cli/src/commands/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@ use rayon::prelude::*;
use tokio::fs;
use vespertide_config::VespertideConfig;
use vespertide_core::TableDef;
use vespertide_exporter::{Orm, render_entity_with_schema, seaorm::SeaOrmExporterWithConfig};
use vespertide_exporter::{
Orm,
prisma::{PrismaExporterWithConfig, PrismaProvider},
render_entity_with_schema,
seaorm::SeaOrmExporterWithConfig,
};
use vespertide_query::DatabaseBackend;

use crate::parallel_config::{EXPORT_RENDER_PAR_MIN_LEN, EXPORT_RENDER_PAR_THRESHOLD};
use crate::utils::load_config;
Expand All @@ -19,6 +25,7 @@ pub enum OrmArg {
Sqlalchemy,
Sqlmodel,
Jpa,
Prisma,
}

impl From<OrmArg> for Orm {
Expand All @@ -28,11 +35,26 @@ impl From<OrmArg> for Orm {
OrmArg::Sqlalchemy => Orm::SqlAlchemy,
OrmArg::Sqlmodel => Orm::SqlModel,
OrmArg::Jpa => Orm::Jpa,
OrmArg::Prisma => Orm::Prisma,
}
}
}

pub async fn cmd_export(orm: OrmArg, export_dir: Option<PathBuf>) -> Result<()> {
/// A Prisma schema is provider-specific (`datasource` provider + `@db.*`
/// native attrs), so the export backend maps onto the Prisma provider.
fn prisma_provider_for_backend(backend: DatabaseBackend) -> PrismaProvider {
match backend {
DatabaseBackend::Postgres => PrismaProvider::Postgres,
DatabaseBackend::MySql => PrismaProvider::MySql,
DatabaseBackend::Sqlite => PrismaProvider::Sqlite,
}
}

pub async fn cmd_export(
orm: OrmArg,
export_dir: Option<PathBuf>,
backend: DatabaseBackend,
) -> Result<()> {
let config = load_config()?;
let models = load_models_recursive(config.models_dir())
.await
Expand All @@ -51,6 +73,12 @@ pub async fn cmd_export(orm: OrmArg, export_dir: Option<PathBuf>) -> Result<()>

let target_root = resolve_export_dir(export_dir, &config);

// Prisma uses a single-file output strategy
if matches!(orm, OrmArg::Prisma) {
let provider = prisma_provider_for_backend(backend);
return cmd_export_prisma(config, normalized_models, target_root, provider).await;
}

// Clean the export directory before regenerating
let orm_kind: Orm = orm.into();
clean_export_dir(&target_root, orm_kind).await?;
Expand Down Expand Up @@ -173,7 +201,7 @@ fn render_export_entity(

/// 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`.
/// If the path doesn't start with `src/`, returns empty string (fallback to `super::` behavior).
fn export_dir_to_crate_prefix(export_dir: &Path) -> String {
let normalized = export_dir.to_string_lossy().replace('\\', "/");
Expand All @@ -189,8 +217,8 @@ fn export_dir_to_crate_prefix(export_dir: &Path) -> String {

/// Convert a relative model file path to Rust module path segments.
///
/// For example: `admin/admin.json` `["admin", "admin"]`
/// `estimate/estimate_checker.vespertide.json` `["estimate", "estimate_checker"]`
/// For example: `admin/admin.json` ??`["admin", "admin"]`
/// `estimate/estimate_checker.vespertide.json` ??`["estimate", "estimate_checker"]`
fn rel_path_to_module_segments(rel_path: &Path) -> Vec<String> {
let mut segments = Vec::new();

Expand Down Expand Up @@ -238,6 +266,7 @@ async fn clean_export_dir(root: &Path, orm: Orm) -> Result<()> {
Orm::SeaOrm => "rs",
Orm::SqlAlchemy | Orm::SqlModel => "py",
Orm::Jpa => "java",
Orm::Prisma => "prisma",
};

clean_dir_recursive(root, ext).await?;
Expand Down Expand Up @@ -330,6 +359,7 @@ fn build_output_path(root: &Path, rel_path: &Path, orm: Orm) -> PathBuf {
Orm::SeaOrm => "rs",
Orm::SqlAlchemy | Orm::SqlModel => "py",
Orm::Jpa => "java",
Orm::Prisma => "prisma",
};
// Java requires filename to match PascalCase class name
let file_stem = if matches!(orm, Orm::Jpa) {
Expand Down Expand Up @@ -426,6 +456,38 @@ async fn ensure_mod_chain(root: &Path, rel_path: &Path) -> Result<()> {
Ok(())
}

async fn cmd_export_prisma(
config: VespertideConfig,
normalized_models: Vec<(TableDef, PathBuf)>,
target_root: PathBuf,
provider: PrismaProvider,
) -> Result<()> {
let all_tables: Vec<TableDef> = normalized_models.iter().map(|(t, _)| t.clone()).collect();
let content = PrismaExporterWithConfig::with_provider(config.prisma(), provider)
.render_schema(&all_tables);

clean_export_dir(&target_root, Orm::Prisma).await?;

if !target_root.exists() {
fs::create_dir_all(&target_root)
.await
.with_context(|| format!("create export dir {}", target_root.display()))?;
}

let out_path = target_root.join("schema.prisma");
fs::write(&out_path, &content)
.await
.with_context(|| format!("write {}", out_path.display()))?;

println!(
"Exported {} model(s) -> {}",
normalized_models.len(),
out_path.display()
);

Ok(())
}

#[async_recursion::async_recursion]
async fn walk_models(
root: &Path,
Expand Down Expand Up @@ -518,7 +580,9 @@ mod tests {
let model = sample_table("users");
write_model(Path::new("models/users.json"), &model);

cmd_export(OrmArg::Seaorm, None).await.unwrap();
cmd_export(OrmArg::Seaorm, None, DatabaseBackend::Postgres)
.await
.unwrap();

let out = PathBuf::from("src/models/users.rs");
assert!(out.exists());
Expand All @@ -543,9 +607,13 @@ mod tests {
write_model(Path::new("models/blog/posts.json"), &model);

let custom = PathBuf::from("out_dir");
cmd_export(OrmArg::Seaorm, Some(custom.clone()))
.await
.unwrap();
cmd_export(
OrmArg::Seaorm,
Some(custom.clone()),
DatabaseBackend::Postgres,
)
.await
.unwrap();

let out = custom.join("blog/posts.rs");
assert!(out.exists());
Expand Down Expand Up @@ -573,7 +641,9 @@ mod tests {
let model = sample_table("items");
write_model(Path::new("models/items.json"), &model);

cmd_export(OrmArg::Sqlalchemy, None).await.unwrap();
cmd_export(OrmArg::Sqlalchemy, None, DatabaseBackend::Postgres)
.await
.unwrap();

let out = PathBuf::from("src/models/items.py");
assert!(out.exists());
Expand All @@ -591,14 +661,73 @@ mod tests {
let model = sample_table("orders");
write_model(Path::new("models/orders.json"), &model);

cmd_export(OrmArg::Sqlmodel, None).await.unwrap();
cmd_export(OrmArg::Sqlmodel, None, DatabaseBackend::Postgres)
.await
.unwrap();

let out = PathBuf::from("src/models/orders.py");
assert!(out.exists());
let content = std_fs::read_to_string(out).unwrap();
assert!(content.contains("orders"));
}

#[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));
}
}

#[tokio::test]
#[serial]
async fn load_models_recursive_returns_empty_when_absent() {
Expand Down Expand Up @@ -688,6 +817,7 @@ mod tests {
#[case(OrmArg::Sqlalchemy, Orm::SqlAlchemy)]
#[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) {
assert_eq!(Orm::from(arg), expected);
}
Expand Down Expand Up @@ -937,7 +1067,7 @@ mod tests {
use std::path::Path;
let root = Path::new("src/models");

// snake_case model PascalCase .java
// snake_case model ??PascalCase .java
let rel_path = Path::new("order_item.json");
let out = build_output_path(root, rel_path, Orm::Jpa);
assert_eq!(out, Path::new("src/models/OrderItem.java"));
Expand Down
Loading