From e60f10f56d3d78788247e0622518824cf9e60c12 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Tue, 24 Mar 2026 19:45:04 +0900 Subject: [PATCH 1/3] Implement jpa --- .../changepack_log_sGDzSkU-VNjgUx_M-V6sq.json | 1 + Cargo.lock | 20 +- crates/vespertide-cli/src/commands/export.rs | 24 +- crates/vespertide-exporter/src/jpa/mod.rs | 1086 +++++++++++++++++ ...ide_exporter__jpa__tests__basic_table.snap | 27 + ...xporter__jpa__tests__nullable_columns.snap | 24 + ...a__tests__table_with_all_simple_types.snap | 76 ++ ..._jpa__tests__table_with_complex_types.snap | 31 + ...sts__table_with_composite_constraints.snap | 30 + ...rter__jpa__tests__table_with_defaults.snap | 27 + ...exporter__jpa__tests__table_with_enum.snap | 28 + ...r__jpa__tests__table_with_foreign_key.snap | 27 + ...__jpa__tests__table_with_integer_enum.snap | 36 + crates/vespertide-exporter/src/lib.rs | 4 +- crates/vespertide-exporter/src/orm.rs | 10 +- ...m__tests__render_entity_snapshots@jpa.snap | 17 + ...nder_entity_with_schema_snapshots@jpa.snap | 17 + 17 files changed, 1472 insertions(+), 13 deletions(-) create mode 100644 .changepacks/changepack_log_sGDzSkU-VNjgUx_M-V6sq.json create mode 100644 crates/vespertide-exporter/src/jpa/mod.rs create mode 100644 crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests__basic_table.snap create mode 100644 crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests__nullable_columns.snap create mode 100644 crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests__table_with_all_simple_types.snap create mode 100644 crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests__table_with_complex_types.snap create mode 100644 crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests__table_with_composite_constraints.snap create mode 100644 crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests__table_with_defaults.snap create mode 100644 crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests__table_with_enum.snap create mode 100644 crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests__table_with_foreign_key.snap create mode 100644 crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests__table_with_integer_enum.snap create mode 100644 crates/vespertide-exporter/src/snapshots/vespertide_exporter__orm__tests__render_entity_snapshots@jpa.snap create mode 100644 crates/vespertide-exporter/src/snapshots/vespertide_exporter__orm__tests__render_entity_with_schema_snapshots@jpa.snap diff --git a/.changepacks/changepack_log_sGDzSkU-VNjgUx_M-V6sq.json b/.changepacks/changepack_log_sGDzSkU-VNjgUx_M-V6sq.json new file mode 100644 index 00000000..0cdab5d5 --- /dev/null +++ b/.changepacks/changepack_log_sGDzSkU-VNjgUx_M-V6sq.json @@ -0,0 +1 @@ +{"changes":{"crates/vespertide-macro/Cargo.toml":"Patch","crates/vespertide-config/Cargo.toml":"Patch","crates/vespertide-planner/Cargo.toml":"Patch","crates/vespertide-query/Cargo.toml":"Patch","crates/vespertide-loader/Cargo.toml":"Patch","crates/vespertide/Cargo.toml":"Patch","crates/vespertide-naming/Cargo.toml":"Patch","crates/vespertide-cli/Cargo.toml":"Patch","crates/vespertide-core/Cargo.toml":"Patch","crates/vespertide-exporter/Cargo.toml":"Patch"},"note":"Implement jpa","date":"2026-03-24T10:44:57.164009900Z"} \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index ad1f0409..31c2ddb4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3726,7 +3726,7 @@ dependencies = [ [[package]] name = "vespertide" -version = "0.1.51" +version = "0.1.52" dependencies = [ "sea-orm", "tokio", @@ -3736,7 +3736,7 @@ dependencies = [ [[package]] name = "vespertide-cli" -version = "0.1.51" +version = "0.1.52" dependencies = [ "anyhow", "assert_cmd", @@ -3765,7 +3765,7 @@ dependencies = [ [[package]] name = "vespertide-config" -version = "0.1.51" +version = "0.1.52" dependencies = [ "clap", "schemars", @@ -3775,7 +3775,7 @@ dependencies = [ [[package]] name = "vespertide-core" -version = "0.1.51" +version = "0.1.52" dependencies = [ "rstest", "schemars", @@ -3787,7 +3787,7 @@ dependencies = [ [[package]] name = "vespertide-exporter" -version = "0.1.51" +version = "0.1.52" dependencies = [ "insta", "rstest", @@ -3799,7 +3799,7 @@ dependencies = [ [[package]] name = "vespertide-loader" -version = "0.1.51" +version = "0.1.52" dependencies = [ "anyhow", "rstest", @@ -3814,7 +3814,7 @@ dependencies = [ [[package]] name = "vespertide-macro" -version = "0.1.51" +version = "0.1.52" dependencies = [ "proc-macro2", "runtime-macros", @@ -3829,11 +3829,11 @@ dependencies = [ [[package]] name = "vespertide-naming" -version = "0.1.51" +version = "0.1.52" [[package]] name = "vespertide-planner" -version = "0.1.51" +version = "0.1.52" dependencies = [ "insta", "rstest", @@ -3844,7 +3844,7 @@ dependencies = [ [[package]] name = "vespertide-query" -version = "0.1.51" +version = "0.1.52" dependencies = [ "insta", "rstest", diff --git a/crates/vespertide-cli/src/commands/export.rs b/crates/vespertide-cli/src/commands/export.rs index 8e25a6a4..100a0c30 100644 --- a/crates/vespertide-cli/src/commands/export.rs +++ b/crates/vespertide-cli/src/commands/export.rs @@ -15,6 +15,7 @@ pub enum OrmArg { Seaorm, Sqlalchemy, Sqlmodel, + Jpa, } impl From for Orm { @@ -23,6 +24,7 @@ impl From for Orm { OrmArg::Seaorm => Orm::SeaOrm, OrmArg::Sqlalchemy => Orm::SqlAlchemy, OrmArg::Sqlmodel => Orm::SqlModel, + OrmArg::Jpa => Orm::Jpa, } } } @@ -133,6 +135,7 @@ async fn clean_export_dir(root: &Path, orm: Orm) -> Result<()> { let ext = match orm { Orm::SeaOrm => "rs", Orm::SqlAlchemy | Orm::SqlModel => "py", + Orm::Jpa => "java", }; clean_dir_recursive(root, ext).await?; @@ -224,13 +227,32 @@ fn build_output_path(root: &Path, rel_path: &Path, orm: Orm) -> PathBuf { let ext = match orm { Orm::SeaOrm => "rs", Orm::SqlAlchemy | Orm::SqlModel => "py", + Orm::Jpa => "java", }; - out.set_file_name(format!("{}.{}", sanitized, ext)); + // Java requires filename to match PascalCase class name + let file_stem = if matches!(orm, Orm::Jpa) { + to_pascal_case(&sanitized) + } else { + sanitized + }; + out.set_file_name(format!("{}.{}", file_stem, ext)); } out } +fn to_pascal_case(s: &str) -> String { + s.split('_') + .map(|word| { + let mut chars = word.chars(); + match chars.next() { + None => String::new(), + Some(first) => first.to_uppercase().chain(chars).collect(), + } + }) + .collect() +} + fn sanitize_filename(name: &str) -> String { name.chars() .map(|ch| { diff --git a/crates/vespertide-exporter/src/jpa/mod.rs b/crates/vespertide-exporter/src/jpa/mod.rs new file mode 100644 index 00000000..8b1579e1 --- /dev/null +++ b/crates/vespertide-exporter/src/jpa/mod.rs @@ -0,0 +1,1086 @@ +use std::collections::{HashMap, HashSet}; + +use crate::orm::OrmExporter; +use vespertide_core::schema::column::{ + ColumnType, ComplexColumnType, EnumValues, SimpleColumnType, +}; +use vespertide_core::schema::constraint::TableConstraint; +use vespertide_core::{ColumnDef, TableDef}; + +/// Track which Java imports are actually used to generate minimal import statements. +#[derive(Default)] +struct UsedImports { + java_time_types: HashSet<&'static str>, + needs_uuid: bool, + needs_big_decimal: bool, +} + +impl UsedImports { + fn add_column_type(&mut self, col_type: &ColumnType) { + match col_type { + ColumnType::Simple(ty) => match ty { + SimpleColumnType::Date => { + self.java_time_types.insert("LocalDate"); + } + SimpleColumnType::Time => { + self.java_time_types.insert("LocalTime"); + } + SimpleColumnType::Timestamp => { + self.java_time_types.insert("LocalDateTime"); + } + SimpleColumnType::Timestamptz => { + self.java_time_types.insert("OffsetDateTime"); + } + SimpleColumnType::Uuid => { + self.needs_uuid = true; + } + _ => {} + }, + ColumnType::Complex(ty) => { + if let ComplexColumnType::Numeric { .. } = ty { + self.needs_big_decimal = true; + } + } + } + } +} + +pub struct JpaExporter; + +impl OrmExporter for JpaExporter { + fn render_entity(&self, table: &TableDef) -> Result { + render_entity(table) + } + + fn render_entity_with_schema( + &self, + table: &TableDef, + schema: &[TableDef], + ) -> Result { + render_entity_with_schema(table, schema) + } +} + +/// Render a JPA entity for the given table definition. +pub fn render_entity(table: &TableDef) -> Result { + render_entity_inner(table) +} + +/// Render a JPA entity with full schema context for FK chain resolution. +pub fn render_entity_with_schema(table: &TableDef, _schema: &[TableDef]) -> Result { + // FK target types are inferred from ref_table in constraints, + // so schema context is not needed for basic JPA entity generation. + render_entity_inner(table) +} + +fn render_entity_inner(table: &TableDef) -> Result { + let mut lines: Vec = Vec::new(); + + // Collect enums for this table + let enums: Vec<(&str, &EnumValues)> = table + .columns + .iter() + .filter_map(|col| { + if let ColumnType::Complex(ComplexColumnType::Enum { name, values }) = &col.r#type { + Some((name.as_str(), values)) + } else { + None + } + }) + .collect(); + + // Collect FK info + let fk_info = collect_fk_info(&table.constraints); + + // Track used imports (skip FK columns — they render as entity references) + let mut used_imports = UsedImports::default(); + for col in &table.columns { + if !fk_info.contains_key(&col.name) { + used_imports.add_column_type(&col.r#type); + } + } + + // --- Generate imports --- + lines.push("import jakarta.persistence.*;".into()); + + if used_imports.needs_big_decimal { + lines.push("import java.math.BigDecimal;".into()); + } + + let mut time_types: Vec<&str> = used_imports.java_time_types.iter().copied().collect(); + time_types.sort(); + for time_type in &time_types { + lines.push(format!("import java.time.{time_type};")); + } + + if used_imports.needs_uuid { + lines.push("import java.util.UUID;".into()); + } + + lines.push(String::new()); + + // --- Render enum classes --- + for (enum_name, values) in &enums { + render_enum(&mut lines, enum_name, values); + lines.push(String::new()); + } + + // --- Class definition --- + let class_name = to_pascal_case(&table.name); + + // Javadoc from table description + if let Some(ref desc) = table.description { + lines.push(format!("/** {} */", desc.replace('\n', " "))); + } + + lines.push("@Entity".into()); + render_table_annotation(&mut lines, &table.name, &table.constraints); + lines.push(format!("public class {class_name} {{")); + lines.push(String::new()); + + // Collect primary key columns + let pk_columns: HashSet = table + .constraints + .iter() + .filter_map(|c| { + if let TableConstraint::PrimaryKey { columns, .. } = c { + Some(columns.clone()) + } else { + None + } + }) + .flatten() + .collect(); + + let auto_increment = table.constraints.iter().any(|c| { + matches!( + c, + TableConstraint::PrimaryKey { + auto_increment: true, + .. + } + ) + }); + + // Collect single-column unique constraints + let unique_columns: HashSet = table + .constraints + .iter() + .filter_map(|c| { + if let TableConstraint::Unique { columns, .. } = c { + if columns.len() == 1 { + Some(columns[0].clone()) + } else { + None + } + } else { + None + } + }) + .collect(); + + // --- Render fields --- + for col in &table.columns { + let is_pk = pk_columns.contains(&col.name); + let is_unique = unique_columns.contains(&col.name); + + if let Some(fk) = fk_info.get(&col.name) { + render_fk_field(&mut lines, col, is_pk, auto_increment, fk); + } else { + render_field(&mut lines, col, is_pk, auto_increment, is_unique); + } + lines.push(String::new()); + } + + // --- Protected no-arg constructor --- + lines.push(format!(" protected {class_name}() {{")); + lines.push(" }".into()); + + lines.push("}".into()); + lines.push(String::new()); + + Ok(lines.join("\n")) +} + +// --------------------------------------------------------------------------- +// FK info collection +// --------------------------------------------------------------------------- + +struct FkInfo { + ref_table: String, +} + +fn collect_fk_info(constraints: &[TableConstraint]) -> HashMap { + constraints + .iter() + .filter_map(|c| { + if let TableConstraint::ForeignKey { + columns, + ref_table, + ref_columns, + .. + } = c + { + if columns.len() == 1 && ref_columns.len() == 1 { + Some(( + columns[0].clone(), + FkInfo { + ref_table: ref_table.clone(), + }, + )) + } else { + None + } + } else { + None + } + }) + .collect() +} + +// --------------------------------------------------------------------------- +// @Table annotation +// --------------------------------------------------------------------------- + +fn render_table_annotation( + lines: &mut Vec, + table_name: &str, + constraints: &[TableConstraint], +) { + let indexes: Vec<_> = constraints + .iter() + .filter_map(|c| { + if let TableConstraint::Index { name, columns } = c { + Some((name.clone(), columns.clone())) + } else { + None + } + }) + .collect(); + + let unique_constraints: Vec<_> = constraints + .iter() + .filter_map(|c| { + if let TableConstraint::Unique { name, columns } = c { + if columns.len() > 1 { + Some((name.clone(), columns.clone())) + } else { + None + } + } else { + None + } + }) + .collect(); + + if indexes.is_empty() && unique_constraints.is_empty() { + lines.push(format!("@Table(name = \"{table_name}\")")); + return; + } + + let mut annotation = format!("@Table(name = \"{table_name}\""); + + if !indexes.is_empty() { + annotation.push_str(", indexes = {\n"); + for (i, (name, columns)) in indexes.iter().enumerate() { + let col_list = columns.join(", "); + let comma = if i < indexes.len() - 1 { "," } else { "" }; + if let Some(idx_name) = name { + annotation.push_str(&format!( + " @Index(name = \"{idx_name}\", columnList = \"{col_list}\"){comma}\n" + )); + } else { + annotation.push_str(&format!(" @Index(columnList = \"{col_list}\"){comma}\n")); + } + } + annotation.push('}'); + } + + if !unique_constraints.is_empty() { + annotation.push_str(", uniqueConstraints = {\n"); + for (i, (name, columns)) in unique_constraints.iter().enumerate() { + let cols = columns + .iter() + .map(|c| format!("\"{c}\"")) + .collect::>() + .join(", "); + let comma = if i < unique_constraints.len() - 1 { + "," + } else { + "" + }; + if let Some(uq_name) = name { + annotation.push_str(&format!( + " @UniqueConstraint(name = \"{uq_name}\", columnNames = {{{cols}}}){comma}\n" + )); + } else { + annotation.push_str(&format!( + " @UniqueConstraint(columnNames = {{{cols}}}){comma}\n" + )); + } + } + annotation.push('}'); + } + + annotation.push(')'); + lines.push(annotation); +} + +// --------------------------------------------------------------------------- +// Enum rendering +// --------------------------------------------------------------------------- + +fn render_enum(lines: &mut Vec, name: &str, values: &EnumValues) { + let class_name = to_pascal_case(name); + + match values { + EnumValues::String(vals) => { + // Use lowercase constants to match DB values with @Enumerated(EnumType.STRING) + lines.push(format!("enum {class_name} {{")); + let last_idx = vals.len().saturating_sub(1); + for (i, val) in vals.iter().enumerate() { + let sep = if i < last_idx { "," } else { ";" }; + lines.push(format!(" {val}{sep}")); + } + lines.push("}".into()); + } + EnumValues::Integer(vals) => { + lines.push(format!("enum {class_name} {{")); + let last_idx = vals.len().saturating_sub(1); + for (i, val) in vals.iter().enumerate() { + let name_upper = val.name.to_uppercase(); + let sep = if i < last_idx { "," } else { ";" }; + lines.push(format!(" {name_upper}({}){sep}", val.value)); + } + lines.push(String::new()); + lines.push(" private final int value;".into()); + lines.push(String::new()); + lines.push(format!(" {class_name}(int value) {{")); + lines.push(" this.value = value;".into()); + lines.push(" }".into()); + lines.push(String::new()); + lines.push(" public int getValue() {".into()); + lines.push(" return value;".into()); + lines.push(" }".into()); + lines.push("}".into()); + } + } +} + +// --------------------------------------------------------------------------- +// Field rendering +// --------------------------------------------------------------------------- + +fn render_field( + lines: &mut Vec, + col: &ColumnDef, + is_pk: bool, + auto_increment: bool, + is_unique: bool, +) { + let java_type = java_type_for_column(col); + let field_name = to_camel_case(&col.name); + + // Javadoc comment + if let Some(ref comment) = col.comment { + lines.push(format!(" /** {} */", comment.replace('\n', " "))); + } + + // @Id + @GeneratedValue + if is_pk { + lines.push(" @Id".into()); + if auto_increment { + lines.push(" @GeneratedValue(strategy = GenerationType.IDENTITY)".into()); + } + } + + // @Enumerated for string enum types + if let ColumnType::Complex(ComplexColumnType::Enum { + values: EnumValues::String(_), + .. + }) = &col.r#type + { + lines.push(" @Enumerated(EnumType.STRING)".into()); + } + + // @Column annotation + let column_attrs = build_column_attrs(col, is_pk, is_unique); + lines.push(format!(" @Column({column_attrs})")); + + // Field declaration with optional default initializer + let default_init = build_default_initializer(col); + if let Some(ref init) = default_init { + lines.push(format!(" private {java_type} {field_name} = {init};")); + } else { + lines.push(format!(" private {java_type} {field_name};")); + } +} + +fn render_fk_field( + lines: &mut Vec, + col: &ColumnDef, + is_pk: bool, + auto_increment: bool, + fk: &FkInfo, +) { + let entity_type = to_pascal_case(&fk.ref_table); + let field_name = infer_fk_field_name(&col.name); + + // Javadoc comment + if let Some(ref comment) = col.comment { + lines.push(format!(" /** {} */", comment.replace('\n', " "))); + } + + // @Id + @GeneratedValue (rare for FK columns, but handle composite PK+FK) + if is_pk { + lines.push(" @Id".into()); + if auto_increment { + lines.push(" @GeneratedValue(strategy = GenerationType.IDENTITY)".into()); + } + } + + // @ManyToOne + lines.push(" @ManyToOne(fetch = FetchType.LAZY)".into()); + + // @JoinColumn + let mut join_attrs: Vec = vec![format!("name = \"{}\"", col.name)]; + if !col.nullable { + join_attrs.push("nullable = false".into()); + } + lines.push(format!(" @JoinColumn({})", join_attrs.join(", "))); + + // Field declaration + lines.push(format!(" private {entity_type} {field_name};")); +} + +// --------------------------------------------------------------------------- +// @Column attribute building +// --------------------------------------------------------------------------- + +fn build_column_attrs(col: &ColumnDef, is_pk: bool, is_unique: bool) -> String { + let mut attrs: Vec = vec![format!("name = \"{}\"", col.name)]; + + // nullable (skip for PK — always not-null) + if !is_pk && !col.nullable { + attrs.push("nullable = false".into()); + } + + // unique (skip for PK) + if is_unique && !is_pk { + attrs.push("unique = true".into()); + } + + // Type-specific attributes + match &col.r#type { + ColumnType::Complex(ComplexColumnType::Varchar { length }) => { + attrs.push(format!("length = {length}")); + } + ColumnType::Complex(ComplexColumnType::Char { length }) => { + attrs.push(format!("length = {length}")); + } + ColumnType::Complex(ComplexColumnType::Numeric { precision, scale }) => { + attrs.push(format!("precision = {precision}")); + attrs.push(format!("scale = {scale}")); + } + ColumnType::Simple(SimpleColumnType::Text | SimpleColumnType::Xml) => { + attrs.push("columnDefinition = \"TEXT\"".into()); + } + ColumnType::Simple(SimpleColumnType::Json) => { + attrs.push("columnDefinition = \"JSON\"".into()); + } + ColumnType::Simple(SimpleColumnType::Bytea) => { + attrs.push("columnDefinition = \"BYTEA\"".into()); + } + ColumnType::Simple(SimpleColumnType::Interval) => { + attrs.push("columnDefinition = \"INTERVAL\"".into()); + } + ColumnType::Complex(ComplexColumnType::Custom { custom_type }) => { + attrs.push(format!("columnDefinition = \"{custom_type}\"")); + } + _ => {} + } + + attrs.join(", ") +} + +// --------------------------------------------------------------------------- +// Default value handling +// --------------------------------------------------------------------------- + +fn build_default_initializer(col: &ColumnDef) -> Option { + let default = col.default.as_ref()?; + let default_str = default.to_sql(); + + // Skip server-side defaults (function calls like NOW()) + if default_str.contains('(') { + return None; + } + + // Boolean defaults + if default_str == "true" { + return Some("true".into()); + } + if default_str == "false" { + return Some("false".into()); + } + + // String literal defaults + if default_str.starts_with('\'') || default_str.starts_with('"') { + let stripped = default_str.trim_matches(|c| c == '\'' || c == '"'); + return Some(format!("\"{}\"", stripped.replace('"', "\\\""))); + } + + // Numeric defaults + if default_str.parse::().is_ok() || default_str.parse::().is_ok() { + return Some(default_str); + } + + None +} + +// --------------------------------------------------------------------------- +// Type mapping +// --------------------------------------------------------------------------- + +fn column_type_to_java(col_type: &ColumnType) -> &'static str { + match col_type { + ColumnType::Simple(ty) => match ty { + SimpleColumnType::SmallInt => "Short", + SimpleColumnType::Integer => "Integer", + SimpleColumnType::BigInt => "Long", + SimpleColumnType::Real => "Float", + SimpleColumnType::DoublePrecision => "Double", + SimpleColumnType::Boolean => "Boolean", + SimpleColumnType::Text | SimpleColumnType::Xml => "String", + SimpleColumnType::Date => "LocalDate", + SimpleColumnType::Time => "LocalTime", + SimpleColumnType::Timestamp => "LocalDateTime", + SimpleColumnType::Timestamptz => "OffsetDateTime", + SimpleColumnType::Interval => "String", + SimpleColumnType::Bytea => "byte[]", + SimpleColumnType::Uuid => "UUID", + SimpleColumnType::Json => "String", + SimpleColumnType::Inet | SimpleColumnType::Cidr | SimpleColumnType::Macaddr => "String", + }, + ColumnType::Complex(ty) => match ty { + ComplexColumnType::Varchar { .. } | ComplexColumnType::Char { .. } => "String", + ComplexColumnType::Numeric { .. } => "BigDecimal", + ComplexColumnType::Custom { .. } => "String", + // Integer enums are stored as Integer in JPA + ComplexColumnType::Enum { + values: EnumValues::Integer(_), + .. + } => "Integer", + // String enums use the generated enum class — handled separately + ComplexColumnType::Enum { + values: EnumValues::String(_), + .. + } => "String", // placeholder, overridden in render_field + }, + } +} + +/// Get the Java type for a column, including enum class names. +fn java_type_for_column(col: &ColumnDef) -> String { + if let ColumnType::Complex(ComplexColumnType::Enum { + name, + values: EnumValues::String(_), + }) = &col.r#type + { + to_pascal_case(name) + } else { + column_type_to_java(&col.r#type).to_string() + } +} + +// --------------------------------------------------------------------------- +// Naming utilities +// --------------------------------------------------------------------------- + +fn to_pascal_case(s: &str) -> String { + s.split('_') + .map(|word| { + let mut chars = word.chars(); + match chars.next() { + None => String::new(), + Some(first) => first.to_uppercase().chain(chars).collect(), + } + }) + .collect() +} + +fn to_camel_case(s: &str) -> String { + let pascal = to_pascal_case(s); + let mut chars = pascal.chars(); + match chars.next() { + None => String::new(), + Some(first) => { + let lower: String = first.to_lowercase().collect(); + format!("{lower}{}", chars.collect::()) + } + } +} + +fn infer_fk_field_name(column_name: &str) -> String { + let base = column_name.strip_suffix("_id").unwrap_or(column_name); + to_camel_case(base) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use insta::assert_snapshot; + use rstest::rstest; + use vespertide_core::{DefaultValue, NumValue}; + + fn col(name: &str, ty: ColumnType) -> ColumnDef { + ColumnDef { + name: name.to_string(), + r#type: ty, + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + } + } + + #[test] + fn test_basic_table() { + let table = TableDef { + name: "users".into(), + description: Some("User accounts table".into()), + columns: vec![ + ColumnDef { + name: "id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: false, + default: None, + comment: Some("Primary key".into()), + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ColumnDef { + name: "email".into(), + r#type: ColumnType::Simple(SimpleColumnType::Text), + nullable: false, + default: None, + comment: Some("User email address".into()), + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ColumnDef { + name: "name".into(), + r#type: ColumnType::Simple(SimpleColumnType::Text), + nullable: true, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ], + constraints: vec![ + TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + }, + TableConstraint::Unique { + name: None, + columns: vec!["email".into()], + }, + ], + }; + + let result = render_entity(&table).unwrap(); + assert_snapshot!(result); + } + + #[test] + fn test_table_with_enum() { + let table = TableDef { + name: "orders".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + ColumnDef { + name: "status".into(), + r#type: ColumnType::Complex(ComplexColumnType::Enum { + name: "order_status".into(), + values: EnumValues::String(vec![ + "pending".into(), + "shipped".into(), + "delivered".into(), + ]), + }), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + }], + }; + + let result = render_entity(&table).unwrap(); + assert_snapshot!(result); + } + + #[test] + fn test_table_with_integer_enum() { + let table = TableDef { + name: "tasks".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + ColumnDef { + name: "priority".into(), + r#type: ColumnType::Complex(ComplexColumnType::Enum { + name: "priority_level".into(), + values: EnumValues::Integer(vec![ + NumValue { + name: "low".into(), + value: 0, + }, + NumValue { + name: "medium".into(), + value: 10, + }, + NumValue { + name: "high".into(), + value: 20, + }, + ]), + }), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: false, + columns: vec!["id".into()], + }], + }; + + let result = render_entity(&table).unwrap(); + assert_snapshot!(result); + } + + #[test] + fn test_table_with_foreign_key() { + let table = TableDef { + name: "posts".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + ColumnDef { + name: "author_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + col("title", ColumnType::Simple(SimpleColumnType::Text)), + ], + constraints: vec![ + TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + }, + TableConstraint::ForeignKey { + name: None, + columns: vec!["author_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + TableConstraint::Index { + name: Some("ix_posts__author_id".into()), + columns: vec!["author_id".into()], + }, + ], + }; + + let result = render_entity(&table).unwrap(); + assert_snapshot!(result); + } + + #[test] + fn test_table_with_all_simple_types() { + let table = TableDef { + name: "type_test".into(), + description: None, + columns: vec![ + col( + "col_smallint", + ColumnType::Simple(SimpleColumnType::SmallInt), + ), + col("col_integer", ColumnType::Simple(SimpleColumnType::Integer)), + col("col_bigint", ColumnType::Simple(SimpleColumnType::BigInt)), + col("col_real", ColumnType::Simple(SimpleColumnType::Real)), + col( + "col_double", + ColumnType::Simple(SimpleColumnType::DoublePrecision), + ), + col("col_text", ColumnType::Simple(SimpleColumnType::Text)), + col("col_boolean", ColumnType::Simple(SimpleColumnType::Boolean)), + col("col_date", ColumnType::Simple(SimpleColumnType::Date)), + col("col_time", ColumnType::Simple(SimpleColumnType::Time)), + col( + "col_timestamp", + ColumnType::Simple(SimpleColumnType::Timestamp), + ), + col( + "col_timestamptz", + ColumnType::Simple(SimpleColumnType::Timestamptz), + ), + col( + "col_interval", + ColumnType::Simple(SimpleColumnType::Interval), + ), + col("col_bytea", ColumnType::Simple(SimpleColumnType::Bytea)), + col("col_uuid", ColumnType::Simple(SimpleColumnType::Uuid)), + col("col_json", ColumnType::Simple(SimpleColumnType::Json)), + col("col_inet", ColumnType::Simple(SimpleColumnType::Inet)), + col("col_cidr", ColumnType::Simple(SimpleColumnType::Cidr)), + col("col_macaddr", ColumnType::Simple(SimpleColumnType::Macaddr)), + col("col_xml", ColumnType::Simple(SimpleColumnType::Xml)), + ], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: false, + columns: vec!["col_integer".into()], + }], + }; + + let result = render_entity(&table).unwrap(); + assert_snapshot!(result); + } + + #[test] + fn test_table_with_complex_types() { + let table = TableDef { + name: "products".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col( + "name", + ColumnType::Complex(ComplexColumnType::Varchar { length: 200 }), + ), + col( + "price", + ColumnType::Complex(ComplexColumnType::Numeric { + precision: 12, + scale: 2, + }), + ), + col( + "code", + ColumnType::Complex(ComplexColumnType::Char { length: 10 }), + ), + col( + "metadata", + ColumnType::Complex(ComplexColumnType::Custom { + custom_type: "JSONB".into(), + }), + ), + ], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + }], + }; + + let result = render_entity(&table).unwrap(); + assert_snapshot!(result); + } + + #[test] + fn test_table_with_defaults() { + let table = TableDef { + name: "articles".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + ColumnDef { + name: "published".into(), + r#type: ColumnType::Simple(SimpleColumnType::Boolean), + nullable: false, + default: Some(DefaultValue::Bool(false)), + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ColumnDef { + name: "view_count".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: false, + default: Some(DefaultValue::Integer(0)), + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ColumnDef { + name: "status".into(), + r#type: ColumnType::Simple(SimpleColumnType::Text), + nullable: false, + default: Some(DefaultValue::String("'draft'".into())), + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + }], + }; + + let result = render_entity(&table).unwrap(); + assert_snapshot!(result); + } + + #[test] + fn test_table_with_composite_constraints() { + let table = TableDef { + name: "order_items".into(), + description: None, + columns: vec![ + col("order_id", ColumnType::Simple(SimpleColumnType::Integer)), + col("product_id", ColumnType::Simple(SimpleColumnType::Integer)), + col("quantity", ColumnType::Simple(SimpleColumnType::Integer)), + ], + constraints: vec![ + TableConstraint::PrimaryKey { + auto_increment: false, + columns: vec!["order_id".into(), "product_id".into()], + }, + TableConstraint::ForeignKey { + name: None, + columns: vec!["order_id".into()], + ref_table: "orders".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + TableConstraint::ForeignKey { + name: None, + columns: vec!["product_id".into()], + ref_table: "products".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + TableConstraint::Unique { + name: Some("uq_order_items__order_product".into()), + columns: vec!["order_id".into(), "product_id".into()], + }, + TableConstraint::Index { + name: Some("ix_order_items__order_id".into()), + columns: vec!["order_id".into()], + }, + ], + }; + + let result = render_entity(&table).unwrap(); + assert_snapshot!(result); + } + + #[test] + fn test_nullable_columns() { + let table = TableDef { + name: "profiles".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + ColumnDef { + name: "bio".into(), + r#type: ColumnType::Simple(SimpleColumnType::Text), + nullable: true, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ColumnDef { + name: "avatar_url".into(), + r#type: ColumnType::Complex(ComplexColumnType::Varchar { length: 500 }), + nullable: true, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + }], + }; + + let result = render_entity(&table).unwrap(); + assert_snapshot!(result); + } + + #[rstest] + #[case("order_item", "OrderItem")] + #[case("users", "Users")] + #[case("a", "A")] + #[case("user_profile_image", "UserProfileImage")] + fn test_to_pascal_case(#[case] input: &str, #[case] expected: &str) { + assert_eq!(to_pascal_case(input), expected); + } + + #[rstest] + #[case("created_at", "createdAt")] + #[case("id", "id")] + #[case("user_profile_image", "userProfileImage")] + fn test_to_camel_case(#[case] input: &str, #[case] expected: &str) { + assert_eq!(to_camel_case(input), expected); + } + + #[rstest] + #[case("customer_id", "customer")] + #[case("author_user_id", "authorUser")] + #[case("parent", "parent")] + fn test_infer_fk_field_name(#[case] input: &str, #[case] expected: &str) { + assert_eq!(infer_fk_field_name(input), expected); + } +} diff --git a/crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests__basic_table.snap b/crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests__basic_table.snap new file mode 100644 index 00000000..fbacb0ee --- /dev/null +++ b/crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests__basic_table.snap @@ -0,0 +1,27 @@ +--- +source: crates/vespertide-exporter/src/jpa/mod.rs +expression: result +--- +import jakarta.persistence.*; + +/** User accounts table */ +@Entity +@Table(name = "users") +public class Users { + + /** Primary key */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Integer id; + + /** User email address */ + @Column(name = "email", nullable = false, unique = true, columnDefinition = "TEXT") + private String email; + + @Column(name = "name", columnDefinition = "TEXT") + private String name; + + protected Users() { + } +} diff --git a/crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests__nullable_columns.snap b/crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests__nullable_columns.snap new file mode 100644 index 00000000..cdefea6d --- /dev/null +++ b/crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests__nullable_columns.snap @@ -0,0 +1,24 @@ +--- +source: crates/vespertide-exporter/src/jpa/mod.rs +expression: result +--- +import jakarta.persistence.*; + +@Entity +@Table(name = "profiles") +public class Profiles { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Integer id; + + @Column(name = "bio", columnDefinition = "TEXT") + private String bio; + + @Column(name = "avatar_url", length = 500) + private String avatarUrl; + + protected Profiles() { + } +} diff --git a/crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests__table_with_all_simple_types.snap b/crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests__table_with_all_simple_types.snap new file mode 100644 index 00000000..a11723ec --- /dev/null +++ b/crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests__table_with_all_simple_types.snap @@ -0,0 +1,76 @@ +--- +source: crates/vespertide-exporter/src/jpa/mod.rs +expression: result +--- +import jakarta.persistence.*; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.util.UUID; + +@Entity +@Table(name = "type_test") +public class TypeTest { + + @Column(name = "col_smallint", nullable = false) + private Short colSmallint; + + @Id + @Column(name = "col_integer") + private Integer colInteger; + + @Column(name = "col_bigint", nullable = false) + private Long colBigint; + + @Column(name = "col_real", nullable = false) + private Float colReal; + + @Column(name = "col_double", nullable = false) + private Double colDouble; + + @Column(name = "col_text", nullable = false, columnDefinition = "TEXT") + private String colText; + + @Column(name = "col_boolean", nullable = false) + private Boolean colBoolean; + + @Column(name = "col_date", nullable = false) + private LocalDate colDate; + + @Column(name = "col_time", nullable = false) + private LocalTime colTime; + + @Column(name = "col_timestamp", nullable = false) + private LocalDateTime colTimestamp; + + @Column(name = "col_timestamptz", nullable = false) + private OffsetDateTime colTimestamptz; + + @Column(name = "col_interval", nullable = false, columnDefinition = "INTERVAL") + private String colInterval; + + @Column(name = "col_bytea", nullable = false, columnDefinition = "BYTEA") + private byte[] colBytea; + + @Column(name = "col_uuid", nullable = false) + private UUID colUuid; + + @Column(name = "col_json", nullable = false, columnDefinition = "JSON") + private String colJson; + + @Column(name = "col_inet", nullable = false) + private String colInet; + + @Column(name = "col_cidr", nullable = false) + private String colCidr; + + @Column(name = "col_macaddr", nullable = false) + private String colMacaddr; + + @Column(name = "col_xml", nullable = false, columnDefinition = "TEXT") + private String colXml; + + protected TypeTest() { + } +} diff --git a/crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests__table_with_complex_types.snap b/crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests__table_with_complex_types.snap new file mode 100644 index 00000000..ba10a110 --- /dev/null +++ b/crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests__table_with_complex_types.snap @@ -0,0 +1,31 @@ +--- +source: crates/vespertide-exporter/src/jpa/mod.rs +expression: result +--- +import jakarta.persistence.*; +import java.math.BigDecimal; + +@Entity +@Table(name = "products") +public class Products { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Integer id; + + @Column(name = "name", nullable = false, length = 200) + private String name; + + @Column(name = "price", nullable = false, precision = 12, scale = 2) + private BigDecimal price; + + @Column(name = "code", nullable = false, length = 10) + private String code; + + @Column(name = "metadata", nullable = false, columnDefinition = "JSONB") + private String metadata; + + protected Products() { + } +} diff --git a/crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests__table_with_composite_constraints.snap b/crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests__table_with_composite_constraints.snap new file mode 100644 index 00000000..001480b1 --- /dev/null +++ b/crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests__table_with_composite_constraints.snap @@ -0,0 +1,30 @@ +--- +source: crates/vespertide-exporter/src/jpa/mod.rs +expression: result +--- +import jakarta.persistence.*; + +@Entity +@Table(name = "order_items", indexes = { + @Index(name = "ix_order_items__order_id", columnList = "order_id") +}, uniqueConstraints = { + @UniqueConstraint(name = "uq_order_items__order_product", columnNames = {"order_id", "product_id"}) +}) +public class OrderItems { + + @Id + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "order_id", nullable = false) + private Orders order; + + @Id + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "product_id", nullable = false) + private Products product; + + @Column(name = "quantity", nullable = false) + private Integer quantity; + + protected OrderItems() { + } +} diff --git a/crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests__table_with_defaults.snap b/crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests__table_with_defaults.snap new file mode 100644 index 00000000..0f08d132 --- /dev/null +++ b/crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests__table_with_defaults.snap @@ -0,0 +1,27 @@ +--- +source: crates/vespertide-exporter/src/jpa/mod.rs +expression: result +--- +import jakarta.persistence.*; + +@Entity +@Table(name = "articles") +public class Articles { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Integer id; + + @Column(name = "published", nullable = false) + private Boolean published = false; + + @Column(name = "view_count", nullable = false) + private Integer viewCount = 0; + + @Column(name = "status", nullable = false, columnDefinition = "TEXT") + private String status = "draft"; + + protected Articles() { + } +} diff --git a/crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests__table_with_enum.snap b/crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests__table_with_enum.snap new file mode 100644 index 00000000..edf317f2 --- /dev/null +++ b/crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests__table_with_enum.snap @@ -0,0 +1,28 @@ +--- +source: crates/vespertide-exporter/src/jpa/mod.rs +expression: result +--- +import jakarta.persistence.*; + +enum OrderStatus { + pending, + shipped, + delivered; +} + +@Entity +@Table(name = "orders") +public class Orders { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Integer id; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private OrderStatus status; + + protected Orders() { + } +} diff --git a/crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests__table_with_foreign_key.snap b/crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests__table_with_foreign_key.snap new file mode 100644 index 00000000..a82221f2 --- /dev/null +++ b/crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests__table_with_foreign_key.snap @@ -0,0 +1,27 @@ +--- +source: crates/vespertide-exporter/src/jpa/mod.rs +expression: result +--- +import jakarta.persistence.*; + +@Entity +@Table(name = "posts", indexes = { + @Index(name = "ix_posts__author_id", columnList = "author_id") +}) +public class Posts { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "author_id", nullable = false) + private Users author; + + @Column(name = "title", nullable = false, columnDefinition = "TEXT") + private String title; + + protected Posts() { + } +} diff --git a/crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests__table_with_integer_enum.snap b/crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests__table_with_integer_enum.snap new file mode 100644 index 00000000..722b0ec5 --- /dev/null +++ b/crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests__table_with_integer_enum.snap @@ -0,0 +1,36 @@ +--- +source: crates/vespertide-exporter/src/jpa/mod.rs +expression: result +--- +import jakarta.persistence.*; + +enum PriorityLevel { + LOW(0), + MEDIUM(10), + HIGH(20); + + private final int value; + + PriorityLevel(int value) { + this.value = value; + } + + public int getValue() { + return value; + } +} + +@Entity +@Table(name = "tasks") +public class Tasks { + + @Id + @Column(name = "id") + private Integer id; + + @Column(name = "priority", nullable = false) + private Integer priority; + + protected Tasks() { + } +} diff --git a/crates/vespertide-exporter/src/lib.rs b/crates/vespertide-exporter/src/lib.rs index 83e6cfa7..22da1da2 100644 --- a/crates/vespertide-exporter/src/lib.rs +++ b/crates/vespertide-exporter/src/lib.rs @@ -1,11 +1,13 @@ //! Helpers to convert `TableDef` models into ORM-specific representations -//! such as SeaORM, SQLAlchemy, and SQLModel. +//! such as SeaORM, SQLAlchemy, SQLModel, and JPA. +pub mod jpa; pub mod orm; pub mod seaorm; pub mod sqlalchemy; pub mod sqlmodel; +pub use jpa::JpaExporter; pub use orm::{Orm, OrmExporter, render_entity, render_entity_with_schema}; pub use seaorm::{SeaOrmExporter, render_entity as render_seaorm_entity}; pub use sqlalchemy::SqlAlchemyExporter; diff --git a/crates/vespertide-exporter/src/orm.rs b/crates/vespertide-exporter/src/orm.rs index 54dac053..cf16fd98 100644 --- a/crates/vespertide-exporter/src/orm.rs +++ b/crates/vespertide-exporter/src/orm.rs @@ -1,6 +1,9 @@ use vespertide_core::TableDef; -use crate::{seaorm::SeaOrmExporter, sqlalchemy::SqlAlchemyExporter, sqlmodel::SqlModelExporter}; +use crate::{ + jpa::JpaExporter, seaorm::SeaOrmExporter, sqlalchemy::SqlAlchemyExporter, + sqlmodel::SqlModelExporter, +}; /// Supported ORM targets. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -8,6 +11,7 @@ pub enum Orm { SeaOrm, SqlAlchemy, SqlModel, + Jpa, } /// Standardized exporter interface for all supported ORMs. @@ -31,6 +35,7 @@ pub fn render_entity(orm: Orm, table: &TableDef) -> Result { Orm::SeaOrm => SeaOrmExporter.render_entity(table), Orm::SqlAlchemy => SqlAlchemyExporter.render_entity(table), Orm::SqlModel => SqlModelExporter.render_entity(table), + Orm::Jpa => JpaExporter.render_entity(table), } } @@ -44,6 +49,7 @@ pub fn render_entity_with_schema( Orm::SeaOrm => SeaOrmExporter.render_entity_with_schema(table, schema), Orm::SqlAlchemy => SqlAlchemyExporter.render_entity_with_schema(table, schema), Orm::SqlModel => SqlModelExporter.render_entity_with_schema(table, schema), + Orm::Jpa => JpaExporter.render_entity_with_schema(table, schema), } } @@ -80,6 +86,7 @@ mod tests { #[case("seaorm", Orm::SeaOrm)] #[case("sqlalchemy", Orm::SqlAlchemy)] #[case("sqlmodel", Orm::SqlModel)] + #[case("jpa", Orm::Jpa)] fn test_render_entity_snapshots(#[case] name: &str, #[case] orm: Orm) { let table = simple_table(); let result = render_entity(orm, &table); @@ -93,6 +100,7 @@ mod tests { #[case("seaorm", Orm::SeaOrm)] #[case("sqlalchemy", Orm::SqlAlchemy)] #[case("sqlmodel", Orm::SqlModel)] + #[case("jpa", Orm::Jpa)] fn test_render_entity_with_schema_snapshots(#[case] name: &str, #[case] orm: Orm) { let table = simple_table(); let schema = vec![table.clone()]; diff --git a/crates/vespertide-exporter/src/snapshots/vespertide_exporter__orm__tests__render_entity_snapshots@jpa.snap b/crates/vespertide-exporter/src/snapshots/vespertide_exporter__orm__tests__render_entity_snapshots@jpa.snap new file mode 100644 index 00000000..7b9ef418 --- /dev/null +++ b/crates/vespertide-exporter/src/snapshots/vespertide_exporter__orm__tests__render_entity_snapshots@jpa.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/orm.rs +expression: result.unwrap() +--- +import jakarta.persistence.*; + +@Entity +@Table(name = "test") +public class Test { + + @Id + @Column(name = "id") + private Integer id; + + protected Test() { + } +} diff --git a/crates/vespertide-exporter/src/snapshots/vespertide_exporter__orm__tests__render_entity_with_schema_snapshots@jpa.snap b/crates/vespertide-exporter/src/snapshots/vespertide_exporter__orm__tests__render_entity_with_schema_snapshots@jpa.snap new file mode 100644 index 00000000..7b9ef418 --- /dev/null +++ b/crates/vespertide-exporter/src/snapshots/vespertide_exporter__orm__tests__render_entity_with_schema_snapshots@jpa.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/orm.rs +expression: result.unwrap() +--- +import jakarta.persistence.*; + +@Entity +@Table(name = "test") +public class Test { + + @Id + @Column(name = "id") + private Integer id; + + protected Test() { + } +} From f70f3149042c832c952acab7fa5bbf5da5937e6c Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Tue, 24 Mar 2026 21:19:01 +0900 Subject: [PATCH 2/3] Add testcase --- crates/vespertide-cli/src/commands/export.rs | 58 +++++++ crates/vespertide-exporter/src/jpa/mod.rs | 143 ++++++++++++++++++ ...s__fk_with_comment_and_auto_increment.snap | 23 +++ ...ests__server_default_and_true_boolean.snap | 31 ++++ ..._jpa__tests__unnamed_index_and_unique.snap | 28 ++++ 5 files changed, 283 insertions(+) create mode 100644 crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests__fk_with_comment_and_auto_increment.snap create mode 100644 crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests__server_default_and_true_boolean.snap create mode 100644 crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests__unnamed_index_and_unique.snap diff --git a/crates/vespertide-cli/src/commands/export.rs b/crates/vespertide-cli/src/commands/export.rs index 100a0c30..b6e577ea 100644 --- a/crates/vespertide-cli/src/commands/export.rs +++ b/crates/vespertide-cli/src/commands/export.rs @@ -603,6 +603,7 @@ mod tests { #[case(OrmArg::Seaorm, Orm::SeaOrm)] #[case(OrmArg::Sqlalchemy, Orm::SqlAlchemy)] #[case(OrmArg::Sqlmodel, Orm::SqlModel)] + #[case(OrmArg::Jpa, Orm::Jpa)] fn orm_arg_maps_to_enum(#[case] arg: OrmArg, #[case] expected: Orm) { assert_eq!(Orm::from(arg), expected); } @@ -767,6 +768,23 @@ mod tests { assert!(!root.join("model.py").exists()); } + #[tokio::test] + async fn clean_export_dir_removes_java_files_for_jpa() { + let tmp = tempdir().unwrap(); + let root = tmp.path().join("export_dir"); + std_fs::create_dir_all(&root).unwrap(); + + std_fs::write(root.join("User.java"), "// java entity").unwrap(); + std_fs::write(root.join("Order.java"), "// java entity").unwrap(); + std_fs::write(root.join("keep.rs"), "// keep this").unwrap(); + + clean_export_dir(&root, Orm::Jpa).await.unwrap(); + + assert!(!root.join("User.java").exists()); + assert!(!root.join("Order.java").exists()); + assert!(root.join("keep.rs").exists()); + } + #[tokio::test] async fn clean_export_dir_handles_missing_directory() { let tmp = tempdir().unwrap(); @@ -828,4 +846,44 @@ mod tests { let result = clean_dir_recursive(&file_path, "rs").await; assert!(result.is_ok()); } + + #[test] + fn build_output_path_jpa_uses_pascal_case_java_extension() { + use std::path::Path; + let root = Path::new("src/models"); + + // 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")); + + // Single word + let rel_path2 = Path::new("users.json"); + let out2 = build_output_path(root, rel_path2, Orm::Jpa); + assert_eq!(out2, Path::new("src/models/Users.java")); + + // Nested path + let rel_path3 = Path::new("blog/post_comment.yaml"); + let out3 = build_output_path(root, rel_path3, Orm::Jpa); + assert_eq!(out3, Path::new("src/models/blog/PostComment.java")); + } + + #[test] + fn build_output_path_jpa_strips_vespertide_suffix() { + use std::path::Path; + let root = Path::new("src/models"); + + let rel_path = Path::new("user.vespertide.json"); + let out = build_output_path(root, rel_path, Orm::Jpa); + assert_eq!(out, Path::new("src/models/User.java")); + } + + #[rstest] + #[case("order_item", "OrderItem")] + #[case("users", "Users")] + #[case("a", "A")] + #[case("user_profile_image", "UserProfileImage")] + fn test_to_pascal_case(#[case] input: &str, #[case] expected: &str) { + assert_eq!(to_pascal_case(input), expected); + } } diff --git a/crates/vespertide-exporter/src/jpa/mod.rs b/crates/vespertide-exporter/src/jpa/mod.rs index 8b1579e1..44e9e5eb 100644 --- a/crates/vespertide-exporter/src/jpa/mod.rs +++ b/crates/vespertide-exporter/src/jpa/mod.rs @@ -1059,11 +1059,153 @@ mod tests { assert_snapshot!(result); } + #[test] + fn test_unnamed_index_and_unique() { + let table = TableDef { + name: "events".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col("venue_id", ColumnType::Simple(SimpleColumnType::Integer)), + col("date", ColumnType::Simple(SimpleColumnType::Date)), + ], + constraints: vec![ + TableConstraint::PrimaryKey { + auto_increment: false, + columns: vec!["id".into()], + }, + TableConstraint::Index { + name: None, + columns: vec!["venue_id".into(), "date".into()], + }, + TableConstraint::Unique { + name: None, + columns: vec!["venue_id".into(), "date".into()], + }, + ], + }; + + let result = render_entity(&table).unwrap(); + assert_snapshot!(result); + } + + #[test] + fn test_fk_with_comment_and_auto_increment() { + let table = TableDef { + name: "child".into(), + description: None, + columns: vec![ + ColumnDef { + name: "parent_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: false, + default: None, + comment: Some("References parent table".into()), + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + col("value", ColumnType::Simple(SimpleColumnType::Text)), + ], + constraints: vec![ + TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["parent_id".into()], + }, + TableConstraint::ForeignKey { + name: None, + columns: vec!["parent_id".into()], + ref_table: "parent".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + ], + }; + + let result = render_entity(&table).unwrap(); + assert_snapshot!(result); + } + + #[test] + fn test_server_default_and_true_boolean() { + let table = TableDef { + name: "logs".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + ColumnDef { + name: "active".into(), + r#type: ColumnType::Simple(SimpleColumnType::Boolean), + nullable: false, + default: Some(DefaultValue::Bool(true)), + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ColumnDef { + name: "created_at".into(), + r#type: ColumnType::Simple(SimpleColumnType::Timestamptz), + nullable: false, + default: Some(DefaultValue::String("NOW()".into())), + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ColumnDef { + name: "score".into(), + r#type: ColumnType::Simple(SimpleColumnType::Real), + nullable: false, + default: Some(DefaultValue::Float(1.5)), + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ColumnDef { + name: "tag".into(), + r#type: ColumnType::Simple(SimpleColumnType::Text), + nullable: false, + default: Some(DefaultValue::String("UNKNOWN_EXPR".into())), + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + }], + }; + + let result = render_entity(&table).unwrap(); + assert_snapshot!(result); + } + + #[test] + fn test_column_type_to_java_string_enum() { + // Exercises the string enum branch in column_type_to_java + let ty = ColumnType::Complex(ComplexColumnType::Enum { + name: "status".into(), + values: EnumValues::String(vec!["a".into()]), + }); + assert_eq!(column_type_to_java(&ty), "String"); + } + #[rstest] #[case("order_item", "OrderItem")] #[case("users", "Users")] #[case("a", "A")] #[case("user_profile_image", "UserProfileImage")] + #[case("", "")] fn test_to_pascal_case(#[case] input: &str, #[case] expected: &str) { assert_eq!(to_pascal_case(input), expected); } @@ -1072,6 +1214,7 @@ mod tests { #[case("created_at", "createdAt")] #[case("id", "id")] #[case("user_profile_image", "userProfileImage")] + #[case("", "")] fn test_to_camel_case(#[case] input: &str, #[case] expected: &str) { assert_eq!(to_camel_case(input), expected); } diff --git a/crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests__fk_with_comment_and_auto_increment.snap b/crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests__fk_with_comment_and_auto_increment.snap new file mode 100644 index 00000000..af8ac24f --- /dev/null +++ b/crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests__fk_with_comment_and_auto_increment.snap @@ -0,0 +1,23 @@ +--- +source: crates/vespertide-exporter/src/jpa/mod.rs +expression: result +--- +import jakarta.persistence.*; + +@Entity +@Table(name = "child") +public class Child { + + /** References parent table */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_id", nullable = false) + private Parent parent; + + @Column(name = "value", nullable = false, columnDefinition = "TEXT") + private String value; + + protected Child() { + } +} diff --git a/crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests__server_default_and_true_boolean.snap b/crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests__server_default_and_true_boolean.snap new file mode 100644 index 00000000..7536de95 --- /dev/null +++ b/crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests__server_default_and_true_boolean.snap @@ -0,0 +1,31 @@ +--- +source: crates/vespertide-exporter/src/jpa/mod.rs +expression: result +--- +import jakarta.persistence.*; +import java.time.OffsetDateTime; + +@Entity +@Table(name = "logs") +public class Logs { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Integer id; + + @Column(name = "active", nullable = false) + private Boolean active = true; + + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt; + + @Column(name = "score", nullable = false) + private Float score = 1.5; + + @Column(name = "tag", nullable = false, columnDefinition = "TEXT") + private String tag; + + protected Logs() { + } +} diff --git a/crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests__unnamed_index_and_unique.snap b/crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests__unnamed_index_and_unique.snap new file mode 100644 index 00000000..0b5904d7 --- /dev/null +++ b/crates/vespertide-exporter/src/jpa/snapshots/vespertide_exporter__jpa__tests__unnamed_index_and_unique.snap @@ -0,0 +1,28 @@ +--- +source: crates/vespertide-exporter/src/jpa/mod.rs +expression: result +--- +import jakarta.persistence.*; +import java.time.LocalDate; + +@Entity +@Table(name = "events", indexes = { + @Index(columnList = "venue_id, date") +}, uniqueConstraints = { + @UniqueConstraint(columnNames = {"venue_id", "date"}) +}) +public class Events { + + @Id + @Column(name = "id") + private Integer id; + + @Column(name = "venue_id", nullable = false) + private Integer venueId; + + @Column(name = "date", nullable = false) + private LocalDate date; + + protected Events() { + } +} From dd27e92f092533a36eff30adf74e7de031b7be9e Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Tue, 24 Mar 2026 21:24:39 +0900 Subject: [PATCH 3/3] Add testcase --- crates/vespertide-cli/src/commands/export.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/vespertide-cli/src/commands/export.rs b/crates/vespertide-cli/src/commands/export.rs index b6e577ea..6eaefdd1 100644 --- a/crates/vespertide-cli/src/commands/export.rs +++ b/crates/vespertide-cli/src/commands/export.rs @@ -883,6 +883,7 @@ mod tests { #[case("users", "Users")] #[case("a", "A")] #[case("user_profile_image", "UserProfileImage")] + #[case("a__b", "AB")] fn test_to_pascal_case(#[case] input: &str, #[case] expected: &str) { assert_eq!(to_pascal_case(input), expected); }