From 8febffc866d60d3238756d9e4a4e4b5e30ce884e Mon Sep 17 00:00:00 2001 From: 2heunxun Date: Thu, 27 Aug 2026 16:15:40 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat(exporter):=20GORM=20=EC=9D=B5=EC=8A=A4?= =?UTF-8?q?=ED=8F=AC=ED=84=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/vespertide-exporter/src/gorm/mod.rs | 994 ++++++++++++++ .../vespertide-exporter/src/gorm/tests/mod.rs | 1137 +++++++++++++++++ .../src/gorm/tests/relations.rs | 143 +++ ...porter__gorm__tests__all_simple_types.snap | 36 + ...de_exporter__gorm__tests__basic_table.snap | 16 + ...r__gorm__tests__composite_pk_nullable.snap | 16 + ...gorm__tests__has_many_with_constraint.snap | 12 + ...__gorm__tests__server_default_skipped.snap | 17 + ...__gorm__tests__table_with_foreign_key.snap | 14 + ..._gorm__tests__table_with_integer_enum.snap | 20 + ..._gorm__tests__table_with_jsonb_column.snap | 17 + ...__gorm__tests__table_with_string_enum.snap | 20 + 12 files changed, 2442 insertions(+) create mode 100644 crates/vespertide-exporter/src/gorm/mod.rs create mode 100644 crates/vespertide-exporter/src/gorm/tests/mod.rs create mode 100644 crates/vespertide-exporter/src/gorm/tests/relations.rs create mode 100644 crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__all_simple_types.snap create mode 100644 crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__basic_table.snap create mode 100644 crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__composite_pk_nullable.snap create mode 100644 crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__has_many_with_constraint.snap create mode 100644 crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__server_default_skipped.snap create mode 100644 crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__table_with_foreign_key.snap create mode 100644 crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__table_with_integer_enum.snap create mode 100644 crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__table_with_jsonb_column.snap create mode 100644 crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__table_with_string_enum.snap diff --git a/crates/vespertide-exporter/src/gorm/mod.rs b/crates/vespertide-exporter/src/gorm/mod.rs new file mode 100644 index 00000000..03763fe0 --- /dev/null +++ b/crates/vespertide-exporter/src/gorm/mod.rs @@ -0,0 +1,994 @@ +use std::collections::{HashMap, HashSet}; + +use crate::orm::OrmExporter; +use vespertide_config::DEFAULT_GORM_PACKAGE_NAME; +use vespertide_core::schema::column::{ + ColumnType, ComplexColumnType, EnumValues, SimpleColumnKind, SimpleColumnType, +}; +use vespertide_core::schema::constraint::TableConstraint; +use vespertide_core::schema::names::ColumnName; +use vespertide_core::{ColumnDef, DefaultValue, ReferenceAction, ReferenceActionKind, TableDef}; +use vespertide_naming::{IdentifierStart, sanitize_identifier}; + +/// Track which Go imports are actually used to generate minimal import statements. +#[expect( + clippy::struct_excessive_bools, + reason = "four independent import-presence flags; enum would add verbosity without clarity" +)] +#[derive(Default)] +struct UsedImports { + needs_time: bool, + needs_uuid: bool, + needs_datatypes: bool, + needs_decimal: bool, +} + +impl UsedImports { + fn add_column_type(&mut self, col_type: &ColumnType) { + match col_type { + ColumnType::Simple(ty) => match ty { + SimpleColumnType::Date + | SimpleColumnType::Time + | SimpleColumnType::Timestamp + | SimpleColumnType::Timestamptz => { + self.needs_time = true; + } + SimpleColumnType::Uuid => { + self.needs_uuid = true; + } + SimpleColumnType::Json => { + self.needs_datatypes = true; + } + _ => {} + }, + ColumnType::Complex(ty) => { + if let ComplexColumnType::Numeric { .. } = ty { + self.needs_decimal = true; + } + if let ComplexColumnType::Custom { custom_type } = ty + && custom_type.to_uppercase() == "JSONB" + { + self.needs_datatypes = true; + } + } + } + } +} + +pub struct GormExporter; + +impl OrmExporter for GormExporter { + 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) + } +} + +/// GORM exporter that honors `vespertide.json`'s `gorm` config section +/// (currently the effective Go package name — see +/// `VespertideConfig::gorm_package_name`, which resolves an explicit +/// `gorm.package_name` or infers one from the actual export directory — +/// emitted at the top of every file). Mirrors `seaorm::SeaOrmExporterWithConfig`. +pub struct GormExporterWithConfig<'a> { + package_name: &'a str, +} + +impl<'a> GormExporterWithConfig<'a> { + /// `package_name` is the already-resolved effective package name (see + /// `VespertideConfig::gorm_package_name`), not the raw `GormConfig` + /// field — resolving requires the actual export directory, which the + /// `GormConfig` alone doesn't know. + pub fn new(package_name: &'a str) -> Self { + Self { package_name } + } + + pub fn render_entity(&self, table: &TableDef) -> Result { + Ok(render_entity_inner_with_package( + table, + &[], + self.package_name, + )) + } + + pub fn render_entity_with_schema( + &self, + table: &TableDef, + schema: &[TableDef], + ) -> Result { + Ok(render_entity_inner_with_package( + table, + schema, + self.package_name, + )) + } +} + +/// Render a GORM entity for the given table definition. +pub fn render_entity(table: &TableDef) -> Result { + Ok(render_entity_inner(table, &[])) +} + +/// Render a GORM entity with full schema context for reverse-relation (HasMany) generation. +pub fn render_entity_with_schema(table: &TableDef, schema: &[TableDef]) -> Result { + Ok(render_entity_inner(table, schema)) +} + +#[cfg(test)] +pub(crate) fn to_pascal_case_for_tests(s: &str) -> String { + to_pascal_case(s) +} + +fn render_entity_inner(table: &TableDef, schema: &[TableDef]) -> String { + render_entity_inner_with_package(table, schema, DEFAULT_GORM_PACKAGE_NAME) +} + +fn render_entity_inner_with_package( + table: &TableDef, + schema: &[TableDef], + package_name: &str, +) -> String { + let mut lines: Vec = Vec::new(); + + let struct_name = + sanitize_identifier(&to_pascal_case(&table.name), IdentifierStart::Underscore); + + // Find enum names that appear in multiple schema tables (need qualified Go type names) + let conflicting_enums: HashSet = { + let mut counts: HashMap = HashMap::new(); + for col in &table.columns { + if let ColumnType::Complex(ComplexColumnType::Enum { name, .. }) = &col.r#type { + counts + .entry(sanitize_identifier( + &to_pascal_case(name), + IdentifierStart::Underscore, + )) + .or_insert(1); + } + } + for other in schema { + if other.name == table.name { + continue; + } + let mut seen = HashSet::new(); + for col in &other.columns { + if let ColumnType::Complex(ComplexColumnType::Enum { name, .. }) = &col.r#type { + let pascal = + sanitize_identifier(&to_pascal_case(name), IdentifierStart::Underscore); + if seen.insert(pascal.clone()) { + *counts.entry(pascal).or_default() += 1; + } + } + } + } + counts + .into_iter() + .filter(|(_, c)| *c > 1) + .map(|(n, _)| n) + .collect() + }; + + // Collect enums defined in this table's columns, with qualified names where needed + let enums: Vec<(&str, &EnumValues, String)> = table + .columns + .iter() + .filter_map(|col| { + if let ColumnType::Complex(ComplexColumnType::Enum { name, values }) = &col.r#type { + let pascal = + sanitize_identifier(&to_pascal_case(name), IdentifierStart::Underscore); + let qualified = if conflicting_enums.contains(&pascal) { + format!("{struct_name}{pascal}") + } else { + pascal + }; + Some((name.as_str(), values, qualified)) + } else { + None + } + }) + .collect(); + let enum_name_map: HashMap<&str, String> = enums + .iter() + .map(|(name, _, qualified)| (*name, qualified.clone())) + .collect(); + + let fk_by_column = collect_fk_info(&table.constraints); + + let pk_columns: HashSet = table + .constraints + .iter() + .filter_map(|c| { + if let TableConstraint::PrimaryKey { columns, .. } = c { + Some(columns.clone()) + } else { + None + } + }) + .flatten() + .map(|c| c.as_str().to_owned()) + .collect(); + + let auto_increment = table.constraints.iter().any(|c| { + matches!( + c, + TableConstraint::PrimaryKey { + auto_increment: true, + .. + } + ) + }); + + let is_composite_pk = pk_columns.len() > 1; + + let single_unique_columns: HashSet = table + .constraints + .iter() + .filter_map(|c| { + if let TableConstraint::Unique { columns, .. } = c { + if columns.len() == 1 { + Some(columns[0].as_str().to_owned()) + } else { + None + } + } else { + None + } + }) + .collect(); + + let index_map = collect_index_info(&table.constraints); + let composite_unique_map = collect_composite_unique_info(&table.constraints); + + let mut used_imports = UsedImports::default(); + for col in &table.columns { + used_imports.add_column_type(&col.r#type); + } + + let reverse_relations = find_reverse_relations(&table.name, schema); + + // --- Package declaration --- + lines.push(format!("package {package_name}")); + lines.push(String::new()); + + // --- Imports --- + let has_stdlib = used_imports.needs_time; + let has_external = + used_imports.needs_uuid || used_imports.needs_datatypes || used_imports.needs_decimal; + + if has_stdlib || has_external { + lines.push("import (".into()); + if has_stdlib { + lines.push(" \"time\"".into()); + } + if has_stdlib && has_external { + lines.push(String::new()); + } + if used_imports.needs_datatypes { + lines.push(" \"gorm.io/datatypes\"".into()); + } + if used_imports.needs_uuid { + lines.push(" \"github.com/google/uuid\"".into()); + } + if used_imports.needs_decimal { + lines.push(" \"github.com/shopspring/decimal\"".into()); + } + lines.push(")".into()); + lines.push(String::new()); + } + + // --- Enum type declarations --- + for (_, values, qualified_name) in &enums { + render_enum(&mut lines, qualified_name, values); + lines.push(String::new()); + } + + // --- Struct definition --- + if let Some(ref desc) = table.description { + lines.push(format!("// {}", desc.replace('\n', " "))); + } + + lines.push(format!("type {struct_name} struct {{")); + + // Every real column's field name is reserved up front so belongs-to + // relation fields (single-column and composite) can detect a collision + // regardless of which column — FK or plain — happens to come first in + // the table definition. + let used_field_names: HashSet = table + .columns + .iter() + .map(|c| to_go_field_name(&c.name)) + .collect(); + let mut used_relation_names = used_field_names.clone(); + + for col in &table.columns { + let is_pk = pk_columns.contains(col.name.as_str()); + let is_unique = single_unique_columns.contains(col.name.as_str()); + let indexes = index_map + .get(col.name.as_str()) + .map_or(&[][..], Vec::as_slice); + let composite_unique_name = composite_unique_map.get(col.name.as_str()); + + if let Some(ref comment) = col.comment { + lines.push(format!(" // {}", comment.replace('\n', " "))); + } + + render_column_field( + &mut lines, + col, + is_pk, + auto_increment && !is_composite_pk, + is_unique, + indexes, + composite_unique_name, + &enum_name_map, + ); + + if let Some(fk) = fk_by_column.get(col.name.as_str()) { + render_fk_relation_field(&mut lines, col, fk, &mut used_relation_names); + } + } + + // Composite (multi-column) FK relation fields. GORM supports composite + // associations via comma-separated `foreignKey`/`references` tags, unlike + // Django which has no native equivalent. + for fk in collect_composite_fk_info(&table.constraints) { + render_composite_fk_relation_field(&mut lines, &fk, &mut used_relation_names); + } + + // Reverse relation fields (HasMany) derived from schema context + for rel in &reverse_relations { + let mut constraint_parts: Vec = Vec::new(); + if let Some(ref action) = rel.on_delete { + constraint_parts.push(format!("OnDelete:{}", reference_action_str(action))); + } + if let Some(ref action) = rel.on_update { + constraint_parts.push(format!("OnUpdate:{}", reference_action_str(action))); + } + let fk_field = to_go_field_name(&rel.fk_column); + let gorm_tag = if constraint_parts.is_empty() { + format!("foreignKey:{fk_field}") + } else { + format!( + "foreignKey:{fk_field};constraint:{}", + constraint_parts.join(",") + ) + }; + lines.push(format!( + " {field_name} []{ref_struct} `gorm:\"{gorm_tag}\" json:\"-\"`", + field_name = rel.field_name, + ref_struct = + sanitize_identifier(&to_pascal_case(&rel.ref_table), IdentifierStart::Underscore), + )); + } + + lines.push("}".into()); + lines.push(String::new()); + + // --- TableName() method --- + if needs_table_name_method(&table.name, &struct_name) { + lines.push(format!( + "func ({struct_name}) TableName() string {{ return \"{name}\" }}", + name = table.name, + )); + lines.push(String::new()); + } + + lines.join("\n") +} + +// --------------------------------------------------------------------------- +// FK info collection +// --------------------------------------------------------------------------- + +struct FkInfo { + ref_table: String, + on_delete: Option, + on_update: Option, +} + +struct CompositeFkInfo { + local_cols: Vec, + ref_table: String, + ref_cols: Vec, + on_delete: Option, + on_update: Option, +} + +fn collect_composite_fk_info(constraints: &[TableConstraint]) -> Vec { + constraints + .iter() + .filter_map(|c| { + if let TableConstraint::ForeignKey { + columns, + ref_table, + ref_columns, + on_delete, + on_update, + .. + } = c + && columns.len() > 1 + && columns.len() == ref_columns.len() + { + return Some(CompositeFkInfo { + local_cols: columns.iter().map(|c| c.as_str().to_owned()).collect(), + ref_table: ref_table.as_str().to_owned(), + ref_cols: ref_columns.iter().map(|c| c.as_str().to_owned()).collect(), + on_delete: on_delete.clone(), + on_update: on_update.clone(), + }); + } + None + }) + .collect() +} + +fn collect_fk_info(constraints: &[TableConstraint]) -> HashMap { + constraints + .iter() + .filter_map(|c| { + if let TableConstraint::ForeignKey { + columns, + ref_table, + ref_columns, + on_delete, + on_update, + .. + } = c + { + if columns.len() == 1 && ref_columns.len() == 1 { + Some(( + columns[0].as_str().to_owned(), + FkInfo { + ref_table: ref_table.as_str().to_owned(), + on_delete: on_delete.clone(), + on_update: on_update.clone(), + }, + )) + } else { + None + } + } else { + None + } + }) + .collect() +} + +// --------------------------------------------------------------------------- +// Index info collection +// --------------------------------------------------------------------------- + +struct IndexInfo { + name: Option, +} + +fn collect_index_info(constraints: &[TableConstraint]) -> HashMap> { + let mut map: HashMap> = HashMap::new(); + for c in constraints { + if let TableConstraint::Index { name, columns } = c { + for col in columns { + map.entry(col.as_str().to_owned()) + .or_default() + .push(IndexInfo { + name: name.as_ref().map(|n| n.as_str().to_owned()), + }); + } + } + } + map +} + +fn collect_composite_unique_info(constraints: &[TableConstraint]) -> HashMap { + let mut map = HashMap::new(); + for c in constraints { + if let TableConstraint::Unique { name, columns, .. } = c + && columns.len() > 1 + { + let uq_name = name.as_ref().map_or_else( + || { + let parts: Vec<&str> = columns.iter().map(ColumnName::as_str).collect(); + format!("uq_{}", parts.join("_")) + }, + |n| n.as_str().to_owned(), + ); + for col in columns { + map.insert(col.as_str().to_owned(), uq_name.clone()); + } + } + } + map +} + +// --------------------------------------------------------------------------- +// Reverse relation discovery +// --------------------------------------------------------------------------- + +struct ReverseRelation { + field_name: String, + ref_table: String, + fk_column: String, + on_delete: Option, + on_update: Option, +} + +fn find_reverse_relations(table_name: &str, schema: &[TableDef]) -> Vec { + type RawRelation = ( + String, + String, + String, + Option, + Option, + ); + let mut raw: Vec = Vec::new(); + for other in schema { + // Note: self-referencing tables (other.name == table_name) are NOT + // skipped here — a table's own FK column pointing back at itself + // (e.g. categories.parent_id -> categories.id) must still produce a + // reverse has-many ("Children") relation on the same struct. + for c in &other.constraints { + if let TableConstraint::ForeignKey { + columns, + ref_table, + on_delete, + on_update, + .. + } = c + && ref_table.as_str() == table_name + && columns.len() == 1 + { + let fk_col = columns[0].as_str().to_owned(); + let is_self_ref = other.name.as_str() == table_name; + let base_name = if is_self_ref { + "Children".to_string() + } else { + let pascal = sanitize_identifier( + &to_pascal_case(other.name.as_str()), + IdentifierStart::Underscore, + ); + if pascal.ends_with('s') { + pascal + } else { + format!("{pascal}s") + } + }; + raw.push(( + other.name.as_str().to_owned(), + fk_col, + base_name, + on_delete.clone(), + on_update.clone(), + )); + } + } + } + + let mut name_count: HashMap = HashMap::new(); + for (_, _, base_name, _, _) in &raw { + *name_count.entry(base_name.clone()).or_default() += 1; + } + + raw.into_iter() + .map(|(ref_table, fk_col, base_name, on_delete, on_update)| { + let field_name = if *name_count.get(&base_name).unwrap_or(&0) > 1 { + format!("{}By{}", base_name, to_go_field_name(&fk_col)) + } else { + base_name + }; + ReverseRelation { + field_name, + ref_table, + fk_column: fk_col, + on_delete, + on_update, + } + }) + .collect() +} + +// --------------------------------------------------------------------------- +// Enum rendering +// --------------------------------------------------------------------------- + +fn render_enum(lines: &mut Vec, name: &str, values: &EnumValues) { + // `name` is already the sanitized, PascalCased (and possibly struct-qualified) + // identifier built by the caller — re-running `to_pascal_case` here would + // split on the `_` a leading-digit escape (e.g. `_1users`) introduces and + // silently drop it. + let type_name = name; + + let mut rendered = match values { + EnumValues::String(_) => { + vec![ + format!("type {type_name} string"), + String::new(), + "const (".into(), + ] + } + EnumValues::Integer(_) => { + vec![ + format!("type {type_name} int"), + String::new(), + "const (".into(), + ] + } + }; + + match values { + EnumValues::String(vals) => { + for val in vals { + let const_name = format!("{type_name}{}", to_pascal_case(val)); + rendered.push(format!(" {const_name} {type_name} = \"{val}\"")); + } + } + EnumValues::Integer(vals) => { + for val in vals { + let const_name = format!("{type_name}{}", to_pascal_case(&val.name)); + rendered.push(format!(" {const_name} {type_name} = {}", val.value)); + } + } + } + + rendered.push(")".into()); + lines.extend(rendered); +} + +// --------------------------------------------------------------------------- +// Field rendering +// --------------------------------------------------------------------------- + +#[expect( + clippy::too_many_arguments, + reason = "all params are independent field-rendering inputs; a context struct would add noise without reducing coupling" +)] +fn render_column_field( + lines: &mut Vec, + col: &ColumnDef, + is_pk: bool, + auto_increment: bool, + is_unique: bool, + indexes: &[IndexInfo], + composite_unique_name: Option<&String>, + enum_name_map: &HashMap<&str, String>, +) { + let go_type = go_type_for_column_mapped(&col.r#type, col.nullable, enum_name_map); + let field_name = to_go_field_name(&col.name); + let gorm_tag = build_gorm_tag( + col, + is_pk, + auto_increment, + is_unique, + indexes, + composite_unique_name, + ); + + lines.push(format!( + " {field_name} {go_type} `gorm:\"{gorm_tag}\" json:\"{json_name}\"`", + json_name = col.name, + )); +} + +fn render_fk_relation_field( + lines: &mut Vec, + col: &ColumnDef, + fk: &FkInfo, + used_relation_names: &mut HashSet, +) { + let ref_struct = + sanitize_identifier(&to_pascal_case(&fk.ref_table), IdentifierStart::Underscore); + let fk_field_name = to_go_field_name(&col.name); + let mut relation_field_name = infer_relation_field_name(&col.name); + if relation_field_name == fk_field_name { + relation_field_name = format!("{relation_field_name}{ref_struct}"); + } + // The name above only rules out colliding with this FK's own scalar + // field; it can still collide with an unrelated real column (or another + // relation) elsewhere in the table, so fall back to a numbered suffix. + if used_relation_names.contains(&relation_field_name) { + let mut n = 2; + loop { + let candidate = format!("{relation_field_name}{n}"); + if !used_relation_names.contains(&candidate) { + relation_field_name = candidate; + break; + } + n += 1; + } + } + used_relation_names.insert(relation_field_name.clone()); + + let mut constraint_parts: Vec = Vec::new(); + if let Some(ref action) = fk.on_delete { + constraint_parts.push(format!("OnDelete:{}", reference_action_str(action))); + } + if let Some(ref action) = fk.on_update { + constraint_parts.push(format!("OnUpdate:{}", reference_action_str(action))); + } + + let gorm_tag = if constraint_parts.is_empty() { + format!("foreignKey:{fk_field_name}") + } else { + format!( + "foreignKey:{fk_field_name};constraint:{}", + constraint_parts.join(",") + ) + }; + + let type_expr = if col.nullable { + format!("*{ref_struct}") + } else { + ref_struct + }; + + lines.push(format!( + " {relation_field_name} {type_expr} `gorm:\"{gorm_tag}\" json:\"-\"`" + )); +} + +/// Render a belongs-to relation field for a composite (multi-column) FK, +/// using GORM's comma-separated `foreignKey`/`references` tag syntax. +fn render_composite_fk_relation_field( + lines: &mut Vec, + fk: &CompositeFkInfo, + used_relation_names: &mut HashSet, +) { + let ref_struct = + sanitize_identifier(&to_pascal_case(&fk.ref_table), IdentifierStart::Underscore); + + let mut relation_field_name = ref_struct.clone(); + if used_relation_names.contains(&relation_field_name) { + let mut n = 2; + loop { + let candidate = format!("{relation_field_name}{n}"); + if !used_relation_names.contains(&candidate) { + relation_field_name = candidate; + break; + } + n += 1; + } + } + used_relation_names.insert(relation_field_name.clone()); + + let fk_fields: Vec = fk.local_cols.iter().map(|c| to_go_field_name(c)).collect(); + let ref_fields: Vec = fk.ref_cols.iter().map(|c| to_go_field_name(c)).collect(); + + let mut constraint_parts: Vec = Vec::new(); + if let Some(ref action) = fk.on_delete { + constraint_parts.push(format!("OnDelete:{}", reference_action_str(action))); + } + if let Some(ref action) = fk.on_update { + constraint_parts.push(format!("OnUpdate:{}", reference_action_str(action))); + } + + let gorm_tag = if constraint_parts.is_empty() { + format!( + "foreignKey:{};references:{}", + fk_fields.join(","), + ref_fields.join(",") + ) + } else { + format!( + "foreignKey:{};references:{};constraint:{}", + fk_fields.join(","), + ref_fields.join(","), + constraint_parts.join(",") + ) + }; + + lines.push(format!( + " {relation_field_name} {ref_struct} `gorm:\"{gorm_tag}\" json:\"-\"`" + )); +} + +// --------------------------------------------------------------------------- +// GORM tag building +// --------------------------------------------------------------------------- + +fn build_gorm_tag( + col: &ColumnDef, + is_pk: bool, + auto_increment: bool, + is_unique: bool, + indexes: &[IndexInfo], + composite_unique_name: Option<&String>, +) -> String { + let mut parts: Vec = vec![format!("column:{}", col.name)]; + + if is_pk { + parts.push("primaryKey".into()); + } + if is_pk && auto_increment { + parts.push("autoIncrement".into()); + } + if !col.nullable && !is_pk { + parts.push("not null".into()); + } + if is_unique && !is_pk { + parts.push("unique".into()); + } + + match &col.r#type { + ColumnType::Simple(SimpleColumnType::Text) => parts.push("type:text".into()), + ColumnType::Simple(SimpleColumnType::Xml) => parts.push("type:xml".into()), + ColumnType::Simple(SimpleColumnType::Interval) => parts.push("type:interval".into()), + ColumnType::Simple(SimpleColumnType::Date) => parts.push("type:date".into()), + ColumnType::Simple(SimpleColumnType::Time) => parts.push("type:time".into()), + ColumnType::Simple(SimpleColumnType::Uuid) => parts.push("type:uuid".into()), + ColumnType::Complex(ComplexColumnType::Varchar { length }) => { + parts.push(format!("size:{length}")); + } + ColumnType::Complex(ComplexColumnType::Char { length }) => { + parts.push(format!("size:{length}")); + parts.push("type:char".into()); + } + ColumnType::Complex(ComplexColumnType::Numeric { precision, scale }) => { + parts.push(format!("type:numeric({precision},{scale})")); + } + ColumnType::Complex(ComplexColumnType::Custom { custom_type }) => { + parts.push(format!("type:{custom_type}")); + } + _ => {} + } + + if let Some(ref default) = col.default + && let Some(tag) = build_default_tag(default) + { + parts.push(tag); + } + + for idx in indexes { + if let Some(ref name) = idx.name { + parts.push(format!("index:{name}")); + } else { + parts.push("index".into()); + } + } + + if let Some(uq_name) = composite_unique_name { + parts.push(format!("uniqueIndex:{uq_name}")); + } + + parts.join(";") +} + +fn build_default_tag(default: &DefaultValue) -> Option { + let sql = default.to_sql(); + if sql.contains('(') { + return None; // Skip server-side function calls like NOW() + } + Some(format!("default:{sql}")) +} + +// --------------------------------------------------------------------------- +// Type mapping +// --------------------------------------------------------------------------- + +pub(super) fn go_type_for_column_mapped( + col_type: &ColumnType, + nullable: bool, + enum_map: &HashMap<&str, String>, +) -> String { + let base = match col_type { + ColumnType::Complex(ComplexColumnType::Enum { name, .. }) => { + enum_map.get(name.as_str()).cloned().unwrap_or_else(|| { + sanitize_identifier(&to_pascal_case(name), IdentifierStart::Underscore) + }) + } + _ => go_base_type(col_type), + }; + if nullable { format!("*{base}") } else { base } +} + +fn go_base_type(col_type: &ColumnType) -> String { + match col_type { + ColumnType::Simple(ty) => match SimpleColumnKind::from(*ty) { + SimpleColumnKind::SmallInt => "int16".to_string(), + SimpleColumnKind::Integer => "int32".to_string(), + SimpleColumnKind::BigInt => "int64".to_string(), + SimpleColumnKind::Real => "float32".to_string(), + SimpleColumnKind::DoublePrecision => "float64".to_string(), + SimpleColumnKind::Text + | SimpleColumnKind::Xml + | SimpleColumnKind::Inet + | SimpleColumnKind::Cidr + | SimpleColumnKind::Macaddr + | SimpleColumnKind::Interval => "string".to_string(), + SimpleColumnKind::Boolean => "bool".to_string(), + SimpleColumnKind::Date + | SimpleColumnKind::Time + | SimpleColumnKind::Timestamp + | SimpleColumnKind::Timestamptz => "time.Time".to_string(), + SimpleColumnKind::Bytea => "[]byte".to_string(), + SimpleColumnKind::Uuid => "uuid.UUID".to_string(), + SimpleColumnKind::Json => "datatypes.JSON".to_string(), + }, + ColumnType::Complex(ty) => match ty { + ComplexColumnType::Varchar { .. } | ComplexColumnType::Char { .. } => { + "string".to_string() + } + ComplexColumnType::Custom { custom_type } => { + if custom_type.to_uppercase() == "JSONB" { + "datatypes.JSON".to_string() + } else { + "string".to_string() + } + } + ComplexColumnType::Numeric { .. } => "decimal.Decimal".to_string(), + // `#[non_exhaustive]` future-variant guard; unreachable today. + #[cfg(not(tarpaulin_include))] + _ => { + unreachable!("ComplexColumnType is #[non_exhaustive]; all variants matched") + } + }, + } +} + +fn reference_action_str(action: &ReferenceAction) -> &'static str { + match ReferenceActionKind::from(action) { + ReferenceActionKind::Cascade => "CASCADE", + ReferenceActionKind::Restrict => "RESTRICT", + ReferenceActionKind::SetNull => "SET NULL", + ReferenceActionKind::SetDefault => "SET DEFAULT", + ReferenceActionKind::NoAction => "NO ACTION", + } +} + +// --------------------------------------------------------------------------- +// 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() +} + +pub(super) fn to_go_field_name(s: &str) -> String { + let pascal = to_pascal_case(s); + // Apply Go conventions for common abbreviations + let pascal = pascal.replace("Id", "ID"); + // Go identifiers can't start with a digit or contain non-alphanumeric + // characters; a leading `_` is legal (matches Rust module / Java field + // escaping elsewhere in the exporter). + sanitize_identifier(&pascal, IdentifierStart::Underscore) +} + +pub(super) fn infer_relation_field_name(fk_column: &str) -> String { + let base = fk_column.strip_suffix("_id").unwrap_or(fk_column); + sanitize_identifier(&to_pascal_case(base), IdentifierStart::Underscore) +} + +fn pascal_to_snake(s: &str) -> String { + let mut result = String::new(); + for c in s.chars() { + if c.is_uppercase() && !result.is_empty() { + result.push('_'); + } + result.extend(c.to_lowercase()); + } + result +} + +pub(super) fn needs_table_name_method(table_name: &str, struct_name: &str) -> bool { + let snake = pascal_to_snake(struct_name); + let gorm_default = format!("{snake}s"); + gorm_default != table_name +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests; diff --git a/crates/vespertide-exporter/src/gorm/tests/mod.rs b/crates/vespertide-exporter/src/gorm/tests/mod.rs new file mode 100644 index 00000000..5bf88e72 --- /dev/null +++ b/crates/vespertide-exporter/src/gorm/tests/mod.rs @@ -0,0 +1,1137 @@ +use std::collections::HashMap; + +use insta::assert_snapshot; +use rstest::rstest; +use vespertide_core::schema::column::{ + ColumnType, ComplexColumnType, EnumValues, SimpleColumnType, +}; +use vespertide_core::schema::constraint::TableConstraint; +use vespertide_core::{ColumnDef, DefaultValue, NumValue, ReferenceAction, TableDef}; + +use super::{ + GormExporterWithConfig, go_type_for_column_mapped, infer_relation_field_name, + needs_table_name_method, render_entity, render_entity_with_schema, to_go_field_name, +}; + +mod relations; + +fn col(name: &str, ty: ColumnType) -> ColumnDef { + ColumnDef { + name: name.into(), + r#type: ty, + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + } +} + +// ----------------------------------------------------------------------- +// Type mapping unit tests +// ----------------------------------------------------------------------- + +#[rstest] +#[case(ColumnType::Simple(SimpleColumnType::SmallInt), false, "int16")] +#[case(ColumnType::Simple(SimpleColumnType::Integer), false, "int32")] +#[case(ColumnType::Simple(SimpleColumnType::BigInt), false, "int64")] +#[case(ColumnType::Simple(SimpleColumnType::Real), false, "float32")] +#[case( + ColumnType::Simple(SimpleColumnType::DoublePrecision), + false, + "float64" +)] +#[case(ColumnType::Simple(SimpleColumnType::Text), false, "string")] +#[case(ColumnType::Simple(SimpleColumnType::Boolean), false, "bool")] +#[case(ColumnType::Simple(SimpleColumnType::Timestamp), false, "time.Time")] +#[case(ColumnType::Simple(SimpleColumnType::Timestamptz), false, "time.Time")] +#[case(ColumnType::Simple(SimpleColumnType::Date), false, "time.Time")] +#[case(ColumnType::Simple(SimpleColumnType::Time), false, "time.Time")] +#[case(ColumnType::Simple(SimpleColumnType::Uuid), false, "uuid.UUID")] +#[case(ColumnType::Simple(SimpleColumnType::Json), false, "datatypes.JSON")] +#[case(ColumnType::Simple(SimpleColumnType::Bytea), false, "[]byte")] +#[case(ColumnType::Simple(SimpleColumnType::Inet), false, "string")] +#[case(ColumnType::Complex(ComplexColumnType::Varchar { length: 255 }), false, "string")] +#[case(ColumnType::Complex(ComplexColumnType::Numeric { precision: 10, scale: 2 }), false, "decimal.Decimal")] +#[case(ColumnType::Complex(ComplexColumnType::Custom { custom_type: "JSONB".into() }), false, "datatypes.JSON")] +#[case(ColumnType::Complex(ComplexColumnType::Custom { custom_type: "jsonb".into() }), false, "datatypes.JSON")] +#[case(ColumnType::Complex(ComplexColumnType::Custom { custom_type: "TEXT".into() }), false, "string")] +#[case(ColumnType::Simple(SimpleColumnType::Integer), true, "*int32")] +#[case(ColumnType::Simple(SimpleColumnType::Text), true, "*string")] +#[case(ColumnType::Simple(SimpleColumnType::Timestamp), true, "*time.Time")] +fn test_go_type_mapping( + #[case] col_type: ColumnType, + #[case] nullable: bool, + #[case] expected: &str, +) { + assert_eq!( + go_type_for_column_mapped(&col_type, nullable, &HashMap::new()), + expected + ); +} + +#[rstest] +#[case("user_id", "UserID")] +#[case("id", "ID")] +#[case("created_at", "CreatedAt")] +#[case("profile_image", "ProfileImage")] +#[case("media_id", "MediaID")] +fn test_to_go_field_name(#[case] input: &str, #[case] expected: &str) { + assert_eq!(to_go_field_name(input), expected); +} + +#[rstest] +#[case("user_id", "User")] +#[case("author_id", "Author")] +#[case("parent_id", "Parent")] +#[case("node", "Node")] +fn test_infer_relation_field_name(#[case] input: &str, #[case] expected: &str) { + assert_eq!(infer_relation_field_name(input), expected); +} + +#[rstest] +#[case("User", "user", true)] +#[case("User", "users", false)] +#[case("OrderItem", "order_items", false)] +#[case("OrderItem", "order_item", true)] +fn test_needs_table_name_method( + #[case] struct_name: &str, + #[case] table_name: &str, + #[case] expected: bool, +) { + assert_eq!(needs_table_name_method(table_name, struct_name), expected); +} + +// ----------------------------------------------------------------------- +// Snapshot tests (a) simple table - columns only +// ----------------------------------------------------------------------- + +#[test] +fn test_basic_table() { + let table = TableDef { + name: "users".into(), + description: Some("User accounts".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::Complex(ComplexColumnType::Varchar { length: 255 }), + nullable: false, + default: None, + comment: None, + 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, + }, + 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, + }, + ], + constraints: vec![ + TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }, + TableConstraint::Unique { + name: None, + columns: vec!["email".into()], + strategy: vespertide_core::UniqueConstraintStrategy::DeleteDuplicates { + keep: vespertide_core::KeepPolicy::First, + }, + }, + ], + }; + let result = render_entity(&table).unwrap(); + assert_snapshot!(result); +} + +// ----------------------------------------------------------------------- +// Snapshot tests (b) FK +// ----------------------------------------------------------------------- + +#[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()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }, + TableConstraint::ForeignKey { + name: None, + columns: vec!["author_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: Some(ReferenceAction::Cascade), + on_update: None, + orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), + }, + TableConstraint::Index { + name: Some("ix_posts__author_id".into()), + columns: vec!["author_id".into()], + }, + ], + }; + let result = render_entity(&table).unwrap(); + assert_snapshot!(result); +} + +// ----------------------------------------------------------------------- +// Snapshot tests (c) enums +// ----------------------------------------------------------------------- + +#[test] +fn test_table_with_string_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: Some(DefaultValue::String("'pending'".into())), + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }], + }; + 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()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }], + }; + let result = render_entity(&table).unwrap(); + assert_snapshot!(result); +} + +// ----------------------------------------------------------------------- +// Snapshot tests (d) composite PK + nullable +// ----------------------------------------------------------------------- + +#[test] +fn test_composite_pk_nullable() { + 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)), + ColumnDef { + name: "quantity".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ColumnDef { + name: "note".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: false, + columns: vec!["order_id".into(), "product_id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }, + TableConstraint::ForeignKey { + name: None, + columns: vec!["order_id".into()], + ref_table: "orders".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), + }, + TableConstraint::ForeignKey { + name: None, + columns: vec!["product_id".into()], + ref_table: "products".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), + }, + TableConstraint::Unique { + name: Some("uq_order_items__order_product".into()), + columns: vec!["order_id".into(), "product_id".into()], + strategy: vespertide_core::UniqueConstraintStrategy::DeleteDuplicates { + keep: vespertide_core::KeepPolicy::First, + }, + }, + ], + }; + let result = render_entity(&table).unwrap(); + assert_snapshot!(result); +} + +// ----------------------------------------------------------------------- +// All simple types +// ----------------------------------------------------------------------- + +#[test] +fn test_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()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }], + }; + let result = render_entity(&table).unwrap(); + assert_snapshot!(result); +} + +// ----------------------------------------------------------------------- +// Snapshot tests (e) JSONB custom type +// ----------------------------------------------------------------------- + +#[test] +fn test_table_with_jsonb_column() { + let table = TableDef { + name: "documents".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col( + "data", + ColumnType::Complex(ComplexColumnType::Custom { + custom_type: "JSONB".into(), + }), + ), + col("meta", ColumnType::Simple(SimpleColumnType::Json)), + ], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }], + }; + let result = render_entity(&table).unwrap(); + assert_snapshot!(result); +} + +// ----------------------------------------------------------------------- +// Snapshot tests (f) server-side default skipped +// ----------------------------------------------------------------------- + +#[test] +fn test_server_default_skipped() { + let table = TableDef { + name: "events".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + 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: "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, + }, + ], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }], + }; + let result = render_entity(&table).unwrap(); + // Server-side function calls must not appear as GORM default tags + assert!(!result.contains("default:NOW()")); + // Literal integer defaults are still included + assert!(result.contains("default:0")); + assert_snapshot!(result); +} + +// ----------------------------------------------------------------------- +// Conflicting enum names across tables → qualified Go type name +// ----------------------------------------------------------------------- + +#[test] +fn test_conflicting_enum_names_qualified() { + let orders = TableDef { + name: "orders".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col( + "status", + ColumnType::Complex(ComplexColumnType::Enum { + name: "status".into(), + values: EnumValues::String(vec!["pending".into(), "done".into()]), + }), + ), + ], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }], + }; + let tasks = TableDef { + name: "tasks".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col( + "status", + ColumnType::Complex(ComplexColumnType::Enum { + name: "status".into(), + values: EnumValues::String(vec!["open".into(), "closed".into()]), + }), + ), + ], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }], + }; + let schema = vec![orders.clone(), tasks]; + let result = render_entity_with_schema(&orders, &schema).unwrap(); + assert!( + result.contains("OrdersStatus"), + "Expected qualified enum name 'OrdersStatus' in:\n{result}" + ); +} + +// ----------------------------------------------------------------------- +// Char column → type:char + size in GORM tag +// ----------------------------------------------------------------------- + +#[test] +fn test_char_type_column() { + let table = TableDef { + name: "codes".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col( + "code", + ColumnType::Complex(ComplexColumnType::Char { length: 3 }), + ), + ], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains("type:char"), + "Expected type:char in GORM tag" + ); + assert!(result.contains("size:3"), "Expected size:3 in GORM tag"); +} + +// ----------------------------------------------------------------------- +// FK field name collision: infer == go_field → disambiguate with ref struct +// ----------------------------------------------------------------------- + +#[test] +fn test_fk_relation_field_name_collision() { + // Column "user" (no _id suffix): infer→"User", go_field→"User" → same → "UserUsers" + let table = TableDef { + name: "posts".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col("user", ColumnType::Simple(SimpleColumnType::Integer)), + ], + constraints: vec![ + TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }, + TableConstraint::ForeignKey { + name: None, + columns: vec!["user".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), + }, + ], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains("UserUsers"), + "Expected disambiguated relation name 'UserUsers' in:\n{result}" + ); +} + +// ----------------------------------------------------------------------- +// Reverse relation disambiguation: two FKs to same target → ByField suffix +// ----------------------------------------------------------------------- + +#[test] +fn test_reverse_relation_disambiguation() { + let users = TableDef { + name: "users".into(), + description: None, + columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }], + }; + let events = TableDef { + name: "events".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col("creator_id", ColumnType::Simple(SimpleColumnType::Integer)), + col("attendee_id", ColumnType::Simple(SimpleColumnType::Integer)), + ], + constraints: vec![ + TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }, + TableConstraint::ForeignKey { + name: None, + columns: vec!["creator_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), + }, + TableConstraint::ForeignKey { + name: None, + columns: vec!["attendee_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), + }, + ], + }; + let schema = vec![users.clone(), events]; + let result = render_entity_with_schema(&users, &schema).unwrap(); + assert!( + result.contains("EventsByCreatorID"), + "Expected 'EventsByCreatorID' in:\n{result}" + ); + assert!( + result.contains("EventsByAttendeeID"), + "Expected 'EventsByAttendeeID' in:\n{result}" + ); +} + +// ----------------------------------------------------------------------- +// Snapshot tests (g) HasMany with on_delete/on_update constraint +// ----------------------------------------------------------------------- + +#[test] +fn test_has_many_with_constraint() { + let users = TableDef { + name: "users".into(), + description: None, + columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }], + }; + let posts = TableDef { + name: "posts".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col("user_id", ColumnType::Simple(SimpleColumnType::Integer)), + ], + constraints: vec![ + TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }, + TableConstraint::ForeignKey { + name: None, + columns: vec!["user_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: Some(ReferenceAction::Cascade), + on_update: Some(ReferenceAction::Restrict), + orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), + }, + ], + }; + let schema = vec![users.clone(), posts.clone()]; + let result = render_entity_with_schema(&users, &schema).unwrap(); + assert!(result.contains("OnDelete:CASCADE")); + assert!(result.contains("OnUpdate:RESTRICT")); + assert_snapshot!(result); +} + +// ----------------------------------------------------------------------- +// Numeric column: add_column_type needs_decimal, build_gorm_tag Numeric, decimal import +// ----------------------------------------------------------------------- + +#[test] +fn test_numeric_column_gorm() { + let table = TableDef { + name: "prices".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col( + "amount", + ColumnType::Complex(ComplexColumnType::Numeric { + precision: 10, + scale: 2, + }), + ), + ], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains("type:numeric(10,2)"), + "expected numeric GORM tag" + ); + assert!( + result.contains("decimal.Decimal"), + "expected decimal.Decimal type" + ); + assert!( + result.contains("github.com/shopspring/decimal"), + "expected decimal import" + ); +} + +// ----------------------------------------------------------------------- +// Unnamed Index: build_gorm_tag unnamed index tag + collect_index_info inner body +// ----------------------------------------------------------------------- + +#[test] +fn test_unnamed_index_gorm() { + let table = TableDef { + name: "searches".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col("query", ColumnType::Simple(SimpleColumnType::Text)), + ], + constraints: vec![ + TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }, + TableConstraint::Index { + name: None, + columns: vec!["query".into()], + }, + ], + }; + let result = render_entity(&table).unwrap(); + assert!(result.contains(";index\""), "expected unnamed index tag"); +} + +// ----------------------------------------------------------------------- +// Named Index: build_gorm_tag named index tag + collect_index_info name closure +// ----------------------------------------------------------------------- + +#[test] +fn test_named_index_gorm() { + let table = TableDef { + name: "searches".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col("query", ColumnType::Simple(SimpleColumnType::Text)), + ], + constraints: vec![ + TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }, + TableConstraint::Index { + name: Some("ix_searches__query".into()), + columns: vec!["query".into()], + }, + ], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains("index:ix_searches__query"), + "expected named index tag" + ); +} + +// ----------------------------------------------------------------------- +// Unnamed composite unique: collect_composite_unique_info auto-name (uq_{cols}) +// ----------------------------------------------------------------------- + +#[test] +fn test_unnamed_composite_unique_gorm() { + let table = TableDef { + name: "order_lines".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col("order_id", ColumnType::Simple(SimpleColumnType::Integer)), + col( + "sku", + ColumnType::Complex(ComplexColumnType::Varchar { length: 50 }), + ), + ], + constraints: vec![ + TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }, + TableConstraint::Unique { + name: None, + columns: vec!["order_id".into(), "sku".into()], + strategy: vespertide_core::UniqueConstraintStrategy::DeleteDuplicates { + keep: vespertide_core::KeepPolicy::First, + }, + }, + ], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains("uniqueIndex:uq_order_id_sku"), + "expected auto-generated uniqueIndex name" + ); +} + +// ----------------------------------------------------------------------- +// Singular source table name: find_reverse_relations appends 's' for non-plural +// ----------------------------------------------------------------------- + +#[test] +fn test_singular_source_table_name() { + let user = TableDef { + name: "user".into(), + description: None, + columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }], + }; + let comment = TableDef { + name: "comment".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col("user_id", ColumnType::Simple(SimpleColumnType::Integer)), + ], + constraints: vec![ + TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }, + TableConstraint::ForeignKey { + name: None, + columns: vec!["user_id".into()], + ref_table: "user".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), + }, + ], + }; + let schema = vec![user.clone(), comment]; + let result = render_entity_with_schema(&user, &schema).unwrap(); + // "comment" → pascal "Comment" → doesn't end with 's' → appended 's' → "Comments" + assert!( + result.contains("Comments"), + "expected 'Comments' plural for singular 'comment' table" + ); +} + +// ----------------------------------------------------------------------- +// FK with on_update + nullable column: render_fk_relation_field lines 547, 559-560 +// ----------------------------------------------------------------------- + +#[test] +fn test_fk_with_on_update_and_nullable() { + let posts = 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: 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()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }, + TableConstraint::ForeignKey { + name: None, + columns: vec!["author_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: Some(ReferenceAction::Cascade), + on_update: Some(ReferenceAction::Restrict), + orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), + }, + ], + }; + let result = render_entity(&posts).unwrap(); + assert!( + result.contains("OnUpdate:RESTRICT"), + "expected OnUpdate constraint" + ); + assert!( + result.contains("*Users"), + "expected nullable FK pointer type" + ); +} + +// ----------------------------------------------------------------------- +// reference_action_str: SetNull, SetDefault, NoAction (via FK on_delete) +// ----------------------------------------------------------------------- + +#[rstest] +#[case(ReferenceAction::SetNull, "SET NULL")] +#[case(ReferenceAction::SetDefault, "SET DEFAULT")] +#[case(ReferenceAction::NoAction, "NO ACTION")] +fn test_gorm_fk_on_delete_actions(#[case] action: ReferenceAction, #[case] expected: &str) { + let table = TableDef { + name: "posts".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col("author_id", ColumnType::Simple(SimpleColumnType::Integer)), + ], + constraints: vec![ + TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }, + TableConstraint::ForeignKey { + name: None, + columns: vec!["author_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: Some(action), + on_update: None, + orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), + }, + ], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains(&format!("OnDelete:{expected}")), + "expected OnDelete:{expected} in:\n{result}" + ); +} + +// ----------------------------------------------------------------------- +// to_pascal_case: None arm via double-underscore table name +// ----------------------------------------------------------------------- + +#[test] +fn test_gorm_double_underscore_table_name() { + // "order__item" splits into ["order", "", "item"] → empty word hits None => String::new() + let table = TableDef { + name: "order__item".into(), + description: None, + columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains("type OrderItem struct"), + "expected pascal-cased struct with double underscore" + ); +} + +// ----------------------------------------------------------------------- +// collect_composite_unique_info: named branch (|n| n.as_str().to_owned()) +// ----------------------------------------------------------------------- + +#[test] +fn test_named_composite_unique_gorm() { + let table = TableDef { + name: "tenants".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)), + col("name", ColumnType::Simple(SimpleColumnType::Text)), + ], + constraints: vec![ + TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }, + TableConstraint::Unique { + name: Some("uq_tenant_name".into()), + columns: vec!["tenant_id".into(), "name".into()], + strategy: vespertide_core::UniqueConstraintStrategy::DeleteDuplicates { + keep: vespertide_core::KeepPolicy::First, + }, + }, + ], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains("uniqueIndex:uq_tenant_name"), + "expected named uniqueIndex tag in GORM output" + ); +} + +// ----------------------------------------------------------------------- +// GormExporterWithConfig: package_name reaches the `package` declaration +// ----------------------------------------------------------------------- + +fn simple_table() -> TableDef { + TableDef { + name: "users".into(), + description: None, + columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], + constraints: vec![TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }], + } +} + +#[test] +fn test_default_package_name_is_models() { + let table = simple_table(); + let exporter = GormExporterWithConfig::new("models"); + let result = exporter.render_entity(&table).unwrap(); + assert!( + result.starts_with("package models\n"), + "expected default 'package models', got:\n{result}" + ); +} + +#[test] +fn test_custom_package_name_from_config() { + let table = simple_table(); + let exporter = GormExporterWithConfig::new("entities"); + let result = exporter.render_entity(&table).unwrap(); + assert!( + result.starts_with("package entities\n"), + "expected 'package entities', got:\n{result}" + ); +} + +#[test] +fn test_custom_package_name_with_schema_context() { + let table = simple_table(); + let schema = vec![table.clone()]; + let exporter = GormExporterWithConfig::new("entities"); + let result = exporter.render_entity_with_schema(&table, &schema).unwrap(); + assert!( + result.starts_with("package entities\n"), + "expected 'package entities', got:\n{result}" + ); +} diff --git a/crates/vespertide-exporter/src/gorm/tests/relations.rs b/crates/vespertide-exporter/src/gorm/tests/relations.rs new file mode 100644 index 00000000..61b2405c --- /dev/null +++ b/crates/vespertide-exporter/src/gorm/tests/relations.rs @@ -0,0 +1,143 @@ +use super::*; + +// ----------------------------------------------------------------------- +// Composite (multi-column) FK relation field +// ----------------------------------------------------------------------- + +fn composite_fk_table() -> TableDef { + TableDef { + name: "order_items".into(), + description: None, + columns: vec![ + col("order_id", ColumnType::Simple(SimpleColumnType::Integer)), + col("region_id", ColumnType::Simple(SimpleColumnType::Integer)), + ], + constraints: vec![ + TableConstraint::PrimaryKey { + auto_increment: false, + columns: vec!["order_id".into(), "region_id".into()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }, + TableConstraint::ForeignKey { + name: None, + columns: vec!["order_id".into(), "region_id".into()], + ref_table: "order_regions".into(), + ref_columns: vec!["order_id".into(), "region_id".into()], + on_delete: Some(ReferenceAction::Cascade), + on_update: Some(ReferenceAction::Restrict), + orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), + }, + ], + } +} + +#[test] +fn test_composite_fk_relation_field() { + let result = render_entity(&composite_fk_table()).unwrap(); + assert!( + result.contains( + "OrderRegions OrderRegions `gorm:\"foreignKey:OrderID,RegionID;references:OrderID,RegionID;constraint:OnDelete:CASCADE,OnUpdate:RESTRICT\" json:\"-\"`" + ), + "expected composite FK relation field in GORM output, got:\n{result}" + ); +} + +#[test] +fn test_composite_fk_relation_field_name_collision_suffixed() { + // A column already named "OrderRegions" (Go field name) collides with the + // natural composite-FK relation field name, forcing a numeric suffix. + let mut table = composite_fk_table(); + table.columns.push(col( + "order_regions", + ColumnType::Simple(SimpleColumnType::Text), + )); + let result = render_entity(&table).unwrap(); + assert!( + result.contains("OrderRegions2 OrderRegions `gorm:\"foreignKey:OrderID,RegionID"), + "expected suffixed relation field name on collision, got:\n{result}" + ); +} + +#[test] +fn test_composite_fk_relation_field_name_double_collision_increments_suffix() { + // Both "OrderRegions" and "OrderRegions2" are already taken by columns, + // so the collision loop must advance past its first candidate too. + let mut table = composite_fk_table(); + table.columns.push(col( + "order_regions", + ColumnType::Simple(SimpleColumnType::Text), + )); + table.columns.push(col( + "order_regions2", + ColumnType::Simple(SimpleColumnType::Text), + )); + let result = render_entity(&table).unwrap(); + assert!( + result.contains("OrderRegions3 OrderRegions `gorm:\"foreignKey:OrderID,RegionID"), + "expected double-suffixed relation field name on double collision, got:\n{result}" + ); +} + +// ----------------------------------------------------------------------- +// Self-referencing FK (single table referencing itself, e.g. a tree/ +// hierarchy structure: categories.parent_id -> categories.id) +// ----------------------------------------------------------------------- + +fn self_referencing_table() -> TableDef { + TableDef { + name: "categories".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + ColumnDef { + name: "parent_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + 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()], + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }, + TableConstraint::ForeignKey { + name: None, + columns: vec!["parent_id".into()], + ref_table: "categories".into(), + ref_columns: vec!["id".into()], + on_delete: Some(ReferenceAction::SetNull), + on_update: None, + orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), + }, + ], + } +} + +#[test] +fn test_self_referencing_fk_forward_relation() { + let table = self_referencing_table(); + let schema = vec![table.clone()]; + let result = render_entity_with_schema(&table, &schema).unwrap(); + assert!( + result.contains("Parent *Categories `gorm:\"foreignKey:ParentID"), + "expected forward self-ref relation field, got:\n{result}" + ); +} + +#[test] +fn test_self_referencing_fk_reverse_relation() { + let table = self_referencing_table(); + let schema = vec![table.clone()]; + let result = render_entity_with_schema(&table, &schema).unwrap(); + assert!( + result.contains("Children []Categories `gorm:\"foreignKey:ParentID"), + "expected reverse (has-many) self-ref relation field, got:\n{result}" + ); +} diff --git a/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__all_simple_types.snap b/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__all_simple_types.snap new file mode 100644 index 00000000..c7c36956 --- /dev/null +++ b/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__all_simple_types.snap @@ -0,0 +1,36 @@ +--- +source: crates/vespertide-exporter/src/gorm/mod.rs +expression: result +--- +package models + +import ( + "time" + + "gorm.io/datatypes" + "github.com/google/uuid" +) + +type TypeTest struct { + ColSmallint int16 `gorm:"column:col_smallint;not null" json:"col_smallint"` + ColInteger int32 `gorm:"column:col_integer;primaryKey" json:"col_integer"` + ColBigint int64 `gorm:"column:col_bigint;not null" json:"col_bigint"` + ColReal float32 `gorm:"column:col_real;not null" json:"col_real"` + ColDouble float64 `gorm:"column:col_double;not null" json:"col_double"` + ColText string `gorm:"column:col_text;not null;type:text" json:"col_text"` + ColBoolean bool `gorm:"column:col_boolean;not null" json:"col_boolean"` + ColDate time.Time `gorm:"column:col_date;not null;type:date" json:"col_date"` + ColTime time.Time `gorm:"column:col_time;not null;type:time" json:"col_time"` + ColTimestamp time.Time `gorm:"column:col_timestamp;not null" json:"col_timestamp"` + ColTimestamptz time.Time `gorm:"column:col_timestamptz;not null" json:"col_timestamptz"` + ColInterval string `gorm:"column:col_interval;not null;type:interval" json:"col_interval"` + ColBytea []byte `gorm:"column:col_bytea;not null" json:"col_bytea"` + ColUuid uuid.UUID `gorm:"column:col_uuid;not null;type:uuid" json:"col_uuid"` + ColJson datatypes.JSON `gorm:"column:col_json;not null" json:"col_json"` + ColInet string `gorm:"column:col_inet;not null" json:"col_inet"` + ColCidr string `gorm:"column:col_cidr;not null" json:"col_cidr"` + ColMacaddr string `gorm:"column:col_macaddr;not null" json:"col_macaddr"` + ColXml string `gorm:"column:col_xml;not null;type:xml" json:"col_xml"` +} + +func (TypeTest) TableName() string { return "type_test" } diff --git a/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__basic_table.snap b/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__basic_table.snap new file mode 100644 index 00000000..fa115d76 --- /dev/null +++ b/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__basic_table.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/gorm/mod.rs +expression: result +--- +package models + +// User accounts +type Users struct { + // Primary key + ID int32 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + Email string `gorm:"column:email;not null;unique;size:255" json:"email"` + Name *string `gorm:"column:name;type:text" json:"name"` + Active bool `gorm:"column:active;not null;default:true" json:"active"` +} + +func (Users) TableName() string { return "users" } diff --git a/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__composite_pk_nullable.snap b/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__composite_pk_nullable.snap new file mode 100644 index 00000000..a70f0b44 --- /dev/null +++ b/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__composite_pk_nullable.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/gorm/mod.rs +expression: result +--- +package models + +type OrderItems struct { + OrderID int32 `gorm:"column:order_id;primaryKey;uniqueIndex:uq_order_items__order_product" json:"order_id"` + Order Orders `gorm:"foreignKey:OrderID" json:"-"` + ProductID int32 `gorm:"column:product_id;primaryKey;uniqueIndex:uq_order_items__order_product" json:"product_id"` + Product Products `gorm:"foreignKey:ProductID" json:"-"` + Quantity int32 `gorm:"column:quantity;not null" json:"quantity"` + Note *string `gorm:"column:note;type:text" json:"note"` +} + +func (OrderItems) TableName() string { return "order_items" } diff --git a/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__has_many_with_constraint.snap b/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__has_many_with_constraint.snap new file mode 100644 index 00000000..fe836c1f --- /dev/null +++ b/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__has_many_with_constraint.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/gorm/mod.rs +expression: result +--- +package models + +type Users struct { + ID int32 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + Posts []Posts `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE,OnUpdate:RESTRICT" json:"-"` +} + +func (Users) TableName() string { return "users" } diff --git a/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__server_default_skipped.snap b/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__server_default_skipped.snap new file mode 100644 index 00000000..cd226a79 --- /dev/null +++ b/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__server_default_skipped.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/gorm/mod.rs +expression: result +--- +package models + +import ( + "time" +) + +type Events struct { + ID int32 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + CreatedAt time.Time `gorm:"column:created_at;not null" json:"created_at"` + Count int32 `gorm:"column:count;not null;default:0" json:"count"` +} + +func (Events) TableName() string { return "events" } diff --git a/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__table_with_foreign_key.snap b/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__table_with_foreign_key.snap new file mode 100644 index 00000000..37b7ce58 --- /dev/null +++ b/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__table_with_foreign_key.snap @@ -0,0 +1,14 @@ +--- +source: crates/vespertide-exporter/src/gorm/mod.rs +expression: result +--- +package models + +type Posts struct { + ID int32 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + AuthorID int32 `gorm:"column:author_id;not null;index:ix_posts__author_id" json:"author_id"` + Author Users `gorm:"foreignKey:AuthorID;constraint:OnDelete:CASCADE" json:"-"` + Title string `gorm:"column:title;not null;type:text" json:"title"` +} + +func (Posts) TableName() string { return "posts" } diff --git a/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__table_with_integer_enum.snap b/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__table_with_integer_enum.snap new file mode 100644 index 00000000..f1fae1b2 --- /dev/null +++ b/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__table_with_integer_enum.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespertide-exporter/src/gorm/mod.rs +expression: result +--- +package models + +type PriorityLevel int + +const ( + PriorityLevelLow PriorityLevel = 0 + PriorityLevelMedium PriorityLevel = 10 + PriorityLevelHigh PriorityLevel = 20 +) + +type Tasks struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Priority PriorityLevel `gorm:"column:priority;not null" json:"priority"` +} + +func (Tasks) TableName() string { return "tasks" } diff --git a/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__table_with_jsonb_column.snap b/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__table_with_jsonb_column.snap new file mode 100644 index 00000000..73699dda --- /dev/null +++ b/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__table_with_jsonb_column.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/gorm/mod.rs +expression: result +--- +package models + +import ( + "gorm.io/datatypes" +) + +type Documents struct { + ID int32 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + Data datatypes.JSON `gorm:"column:data;not null;type:JSONB" json:"data"` + Meta datatypes.JSON `gorm:"column:meta;not null" json:"meta"` +} + +func (Documents) TableName() string { return "documents" } diff --git a/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__table_with_string_enum.snap b/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__table_with_string_enum.snap new file mode 100644 index 00000000..1fbaeb7c --- /dev/null +++ b/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__table_with_string_enum.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespertide-exporter/src/gorm/mod.rs +expression: result +--- +package models + +type OrderStatus string + +const ( + OrderStatusPending OrderStatus = "pending" + OrderStatusShipped OrderStatus = "shipped" + OrderStatusDelivered OrderStatus = "delivered" +) + +type Orders struct { + ID int32 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + Status OrderStatus `gorm:"column:status;not null;default:'pending'" json:"status"` +} + +func (Orders) TableName() string { return "orders" } From 5cae7000c3f8e477ca1340c438779c5c8893daa4 Mon Sep 17 00:00:00 2001 From: JaeHyunAn <98042706+yyuneu@users.noreply.github.com> Date: Sun, 20 Sep 2026 15:34:51 +0900 Subject: [PATCH 2/6] =?UTF-8?q?refactor(core):=20SimpleColumnType=C2=B7Ref?= =?UTF-8?q?erenceAction=EC=9D=98=20non=5Fexhaustive=20=ED=95=B4=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/vespertide-core/src/schema/column.rs | 4 ---- crates/vespertide-core/src/schema/reference.rs | 11 +---------- crates/vespertide-exporter/src/drizzle/render.rs | 5 +---- crates/vespertide-exporter/src/drizzle/types.rs | 9 --------- crates/vespertide-exporter/src/jpa/types.rs | 3 --- crates/vespertide-exporter/src/prisma/render.rs | 3 +-- crates/vespertide-exporter/src/prisma/types.rs | 3 --- crates/vespertide-exporter/src/sqlalchemy/types.rs | 6 ------ crates/vespertide-exporter/src/utils/python.rs | 3 --- .../src/validate/fk_policy_changes.rs | 3 --- crates/vespertide-query/src/sql/helpers.rs | 2 -- crates/vespertide-query/src/sql/tests/helpers.rs | 1 - schemas/migration.schema.json | 4 ++-- schemas/model.schema.json | 4 ++-- 14 files changed, 7 insertions(+), 54 deletions(-) diff --git a/crates/vespertide-core/src/schema/column.rs b/crates/vespertide-core/src/schema/column.rs index 9bdcb6e5..6827ca07 100644 --- a/crates/vespertide-core/src/schema/column.rs +++ b/crates/vespertide-core/src/schema/column.rs @@ -258,13 +258,9 @@ impl ColumnDef { /// /// Each variant maps directly to a standard SQL type. Use these via /// [`ColumnType::Simple`] when no length, precision, or scale is needed. -/// -/// This enum is `#[non_exhaustive]`: new variants may be added in future releases. -/// Downstream `match` expressions should include a wildcard arm. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] -#[non_exhaustive] pub enum SimpleColumnType { /// 16-bit signed integer (`SMALLINT`). SmallInt, diff --git a/crates/vespertide-core/src/schema/reference.rs b/crates/vespertide-core/src/schema/reference.rs index d4d246ef..63e153ff 100644 --- a/crates/vespertide-core/src/schema/reference.rs +++ b/crates/vespertide-core/src/schema/reference.rs @@ -5,13 +5,9 @@ use serde::{Deserialize, Serialize}; /// Used in `ForeignKeyDef::on_delete` and `ForeignKeyDef::on_update` to control cascading /// behaviour. In JSON model files these are written in `snake_case` /// (e.g. `"on_delete": "cascade"`). -/// -/// This enum is `#[non_exhaustive]`: new variants may be added in future releases. -/// Downstream `match` expressions should include a wildcard arm. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] -#[non_exhaustive] pub enum ReferenceAction { /// Automatically delete or update child rows when the parent row is deleted or updated (`CASCADE`). Cascade, @@ -46,9 +42,7 @@ impl ReferenceAction { #[cfg(test)] mod tests { - //! Coverage-closure tests for `ReferenceAction::to_sql_keyword`. - //! Targets `uncovered-detail.json` lines 40, 41, 42 - //! (`SetNull` / `SetDefault` / `NoAction` match arms). + //! `ReferenceAction::to_sql_keyword` emits one SQL keyword per variant. use super::*; use rstest::rstest; @@ -62,9 +56,6 @@ mod tests { #[case] action: ReferenceAction, #[case] expected: &'static str, ) { - // Each rstest case visits one match arm of to_sql_keyword. The - // SetNull/SetDefault/NoAction cases cover the previously-uncovered - // lines 40, 41, 42. assert_eq!(action.to_sql_keyword(), expected); } } diff --git a/crates/vespertide-exporter/src/drizzle/render.rs b/crates/vespertide-exporter/src/drizzle/render.rs index 34d3d168..08eecc71 100644 --- a/crates/vespertide-exporter/src/drizzle/render.rs +++ b/crates/vespertide-exporter/src/drizzle/render.rs @@ -581,10 +581,7 @@ fn reference_action_to_drizzle(action: &ReferenceAction) -> &'static str { ReferenceAction::Restrict => "restrict", ReferenceAction::SetNull => "set null", ReferenceAction::SetDefault => "set default", - // `NoAction`, plus — `ReferenceAction` is `#[non_exhaustive]` — any - // action added later, which falls back to the SQL default rather than - // to a keyword Drizzle cannot parse. - _ => "no action", + ReferenceAction::NoAction => "no action", } } diff --git a/crates/vespertide-exporter/src/drizzle/types.rs b/crates/vespertide-exporter/src/drizzle/types.rs index 2af9d086..c0b04f94 100644 --- a/crates/vespertide-exporter/src/drizzle/types.rs +++ b/crates/vespertide-exporter/src/drizzle/types.rs @@ -201,9 +201,6 @@ fn pg_ctor(ty: &ColumnType, table: &str, bindings: &FileBindings) -> ColumnCtor SimpleColumnType::Bytea => custom_ctor(&pg_bytea(), bindings), SimpleColumnType::Xml => custom_ctor(&pg_xml(), bindings), SimpleColumnType::Text => ctor("text"), - _ => unreachable!( - "SimpleColumnType is #[non_exhaustive]; all variants are matched above" - ), }, ColumnType::Complex(c) => complex_ctor(c, DrizzleDialect::Pg, table, bindings), } @@ -236,9 +233,6 @@ fn mysql_ctor(ty: &ColumnType, table: &str, bindings: &FileBindings) -> ColumnCt SimpleColumnType::Macaddr => widened("text", String::new(), "macaddr"), SimpleColumnType::Xml => widened("text", String::new(), "xml"), SimpleColumnType::Text => ctor("text"), - _ => unreachable!( - "SimpleColumnType is #[non_exhaustive]; all variants are matched above" - ), }, ColumnType::Complex(c) => complex_ctor(c, DrizzleDialect::Mysql, table, bindings), } @@ -273,9 +267,6 @@ fn sqlite_ctor(ty: &ColumnType, table: &str, bindings: &FileBindings) -> ColumnC SimpleColumnType::Macaddr => widened("text", String::new(), "macaddr"), SimpleColumnType::Xml => widened("text", String::new(), "xml"), SimpleColumnType::Text => ctor("text"), - _ => unreachable!( - "SimpleColumnType is #[non_exhaustive]; all variants are matched above" - ), }, ColumnType::Complex(c) => complex_ctor(c, DrizzleDialect::Sqlite, table, bindings), } diff --git a/crates/vespertide-exporter/src/jpa/types.rs b/crates/vespertide-exporter/src/jpa/types.rs index 7978a099..952e7b02 100644 --- a/crates/vespertide-exporter/src/jpa/types.rs +++ b/crates/vespertide-exporter/src/jpa/types.rs @@ -67,9 +67,6 @@ pub(super) fn column_type_to_java(col_type: &ColumnType) -> &'static str { SimpleColumnType::Timestamptz => "OffsetDateTime", SimpleColumnType::Bytea => "byte[]", SimpleColumnType::Uuid => "UUID", - _ => unreachable!( - "SimpleColumnType is #[non_exhaustive]; all variants are matched above" - ), }, ColumnType::Complex(ty) => match ty { ComplexColumnType::Numeric { .. } => "BigDecimal", diff --git a/crates/vespertide-exporter/src/prisma/render.rs b/crates/vespertide-exporter/src/prisma/render.rs index 0eb2f812..6bd05df0 100644 --- a/crates/vespertide-exporter/src/prisma/render.rs +++ b/crates/vespertide-exporter/src/prisma/render.rs @@ -410,8 +410,7 @@ fn reference_action_to_prisma(action: &ReferenceAction) -> &'static str { ReferenceAction::Restrict => "Restrict", ReferenceAction::SetNull => "SetNull", ReferenceAction::SetDefault => "SetDefault", - // Includes NoAction and unknown/future referential actions. - _ => "NoAction", + ReferenceAction::NoAction => "NoAction", } } diff --git a/crates/vespertide-exporter/src/prisma/types.rs b/crates/vespertide-exporter/src/prisma/types.rs index 94f8ff4c..bfc2231e 100644 --- a/crates/vespertide-exporter/src/prisma/types.rs +++ b/crates/vespertide-exporter/src/prisma/types.rs @@ -40,9 +40,6 @@ pub(super) fn column_type_to_prisma( | SimpleColumnType::Cidr | SimpleColumnType::Macaddr | SimpleColumnType::Xml => "String", - _ => unreachable!( - "SimpleColumnType is #[non_exhaustive]; all variants are matched above" - ), }; format!("{base}{q}") } diff --git a/crates/vespertide-exporter/src/sqlalchemy/types.rs b/crates/vespertide-exporter/src/sqlalchemy/types.rs index 766c6898..fb086130 100644 --- a/crates/vespertide-exporter/src/sqlalchemy/types.rs +++ b/crates/vespertide-exporter/src/sqlalchemy/types.rs @@ -88,9 +88,6 @@ impl UsedTypes<'_> { SimpleColumnType::Inet | SimpleColumnType::Cidr | SimpleColumnType::Macaddr => { self.sa_types.insert("String"); } - _ => unreachable!( - "SimpleColumnType is #[non_exhaustive]; all variants are matched above" - ), } } @@ -139,9 +136,6 @@ pub(super) fn column_type_to_sqlalchemy(col_type: &ColumnType) -> String { SimpleColumnType::Inet | SimpleColumnType::Cidr | SimpleColumnType::Macaddr => { "String(255)".into() } - _ => unreachable!( - "SimpleColumnType is #[non_exhaustive]; all variants are matched above" - ), }, ColumnType::Complex(ty) => match ty { ComplexColumnType::Numeric { precision, scale } => { diff --git a/crates/vespertide-exporter/src/utils/python.rs b/crates/vespertide-exporter/src/utils/python.rs index 77888214..e145f668 100644 --- a/crates/vespertide-exporter/src/utils/python.rs +++ b/crates/vespertide-exporter/src/utils/python.rs @@ -60,9 +60,6 @@ pub(crate) fn column_type_to_python(col_type: &ColumnType, nullable: bool) -> St SimpleColumnType::Bytea => "bytes", SimpleColumnType::Uuid => "UUID", SimpleColumnType::Json => "dict", - _ => unreachable!( - "SimpleColumnType is #[non_exhaustive]; all variants are matched above" - ), }, ColumnType::Complex(ty) => match ty { ComplexColumnType::Numeric { .. } => "Decimal", diff --git a/crates/vespertide-planner/src/validate/fk_policy_changes.rs b/crates/vespertide-planner/src/validate/fk_policy_changes.rs index ca9d601b..9f5b772f 100644 --- a/crates/vespertide-planner/src/validate/fk_policy_changes.rs +++ b/crates/vespertide-planner/src/validate/fk_policy_changes.rs @@ -180,9 +180,6 @@ pub fn render_reference_action(action: Option<&ReferenceAction>) -> &'static str Some(ReferenceAction::SetNull) => "SET NULL", Some(ReferenceAction::SetDefault) => "SET DEFAULT", Some(ReferenceAction::NoAction) | None => "NO ACTION", - // reason: unreachable - exhaustive over current ReferenceAction variants; fallback required only for #[non_exhaustive] future variants - #[cfg(not(tarpaulin_include))] - Some(_) => "(unknown)", } } diff --git a/crates/vespertide-query/src/sql/helpers.rs b/crates/vespertide-query/src/sql/helpers.rs index 1906c911..2e87ebce 100644 --- a/crates/vespertide-query/src/sql/helpers.rs +++ b/crates/vespertide-query/src/sql/helpers.rs @@ -108,7 +108,6 @@ fn apply_simple_column_type( SimpleColumnType::Cidr => apply_postgres_text_fallback_type(col, backend, "CIDR"), SimpleColumnType::Macaddr => apply_postgres_text_fallback_type(col, backend, "MACADDR"), SimpleColumnType::Xml => apply_postgres_text_fallback_type(col, backend, "XML"), - _ => unreachable!("SimpleColumnType is #[non_exhaustive]; all variants are matched above"), } } @@ -219,7 +218,6 @@ pub(crate) fn to_sea_fk_action(action: &ReferenceAction) -> ForeignKeyAction { ReferenceAction::SetNull => ForeignKeyAction::SetNull, ReferenceAction::SetDefault => ForeignKeyAction::SetDefault, ReferenceAction::NoAction => ForeignKeyAction::NoAction, - _ => unreachable!("ReferenceAction is #[non_exhaustive]; all variants are matched above"), } } diff --git a/crates/vespertide-query/src/sql/tests/helpers.rs b/crates/vespertide-query/src/sql/tests/helpers.rs index 49b1848d..45551cdf 100644 --- a/crates/vespertide-query/src/sql/tests/helpers.rs +++ b/crates/vespertide-query/src/sql/tests/helpers.rs @@ -16,7 +16,6 @@ fn reference_action_sql(action: &ReferenceAction) -> &'static str { ReferenceAction::SetNull => "SET NULL", ReferenceAction::SetDefault => "SET DEFAULT", ReferenceAction::NoAction => "NO ACTION", - _ => unreachable!("ReferenceAction is #[non_exhaustive]; all variants are matched above"), } } diff --git a/schemas/migration.schema.json b/schemas/migration.schema.json index c48e9db7..8ea13714 100644 --- a/schemas/migration.schema.json +++ b/schemas/migration.schema.json @@ -1019,7 +1019,7 @@ ] }, "ReferenceAction": { - "description": "The referential action taken on child rows when the referenced parent row changes.\n\nUsed in `ForeignKeyDef::on_delete` and `ForeignKeyDef::on_update` to control cascading\nbehaviour. In JSON model files these are written in `snake_case`\n(e.g. `\"on_delete\": \"cascade\"`).\n\nThis enum is `#[non_exhaustive]`: new variants may be added in future releases.\nDownstream `match` expressions should include a wildcard arm.", + "description": "The referential action taken on child rows when the referenced parent row changes.\n\nUsed in `ForeignKeyDef::on_delete` and `ForeignKeyDef::on_update` to control cascading\nbehaviour. In JSON model files these are written in `snake_case`\n(e.g. `\"on_delete\": \"cascade\"`).", "oneOf": [ { "description": "Automatically delete or update child rows when the parent row is deleted or updated (`CASCADE`).", @@ -1082,7 +1082,7 @@ ] }, "SimpleColumnType": { - "description": "Parameter-free SQL column types supported across all backends.\n\nEach variant maps directly to a standard SQL type. Use these via\n[`ColumnType::Simple`] when no length, precision, or scale is needed.\n\nThis enum is `#[non_exhaustive]`: new variants may be added in future releases.\nDownstream `match` expressions should include a wildcard arm.", + "description": "Parameter-free SQL column types supported across all backends.\n\nEach variant maps directly to a standard SQL type. Use these via\n[`ColumnType::Simple`] when no length, precision, or scale is needed.", "oneOf": [ { "description": "16-bit signed integer (`SMALLINT`).", diff --git a/schemas/model.schema.json b/schemas/model.schema.json index 7a286e24..6baa3264 100644 --- a/schemas/model.schema.json +++ b/schemas/model.schema.json @@ -450,7 +450,7 @@ "description": "Inline primary key declaration on a [`ColumnDef`], supporting both shorthand and full syntax.\n\nIn JSON model files you can write either:\n- `\"primary_key\": true` (shorthand, no auto-increment)\n- `\"primary_key\": {\"auto_increment\": true}` (full object syntax)\n\n[`ColumnDef`]: crate::schema::ColumnDef" }, "ReferenceAction": { - "description": "The referential action taken on child rows when the referenced parent row changes.\n\nUsed in `ForeignKeyDef::on_delete` and `ForeignKeyDef::on_update` to control cascading\nbehaviour. In JSON model files these are written in `snake_case`\n(e.g. `\"on_delete\": \"cascade\"`).\n\nThis enum is `#[non_exhaustive]`: new variants may be added in future releases.\nDownstream `match` expressions should include a wildcard arm.", + "description": "The referential action taken on child rows when the referenced parent row changes.\n\nUsed in `ForeignKeyDef::on_delete` and `ForeignKeyDef::on_update` to control cascading\nbehaviour. In JSON model files these are written in `snake_case`\n(e.g. `\"on_delete\": \"cascade\"`).", "oneOf": [ { "const": "cascade", @@ -513,7 +513,7 @@ "type": "object" }, "SimpleColumnType": { - "description": "Parameter-free SQL column types supported across all backends.\n\nEach variant maps directly to a standard SQL type. Use these via\n[`ColumnType::Simple`] when no length, precision, or scale is needed.\n\nThis enum is `#[non_exhaustive]`: new variants may be added in future releases.\nDownstream `match` expressions should include a wildcard arm.", + "description": "Parameter-free SQL column types supported across all backends.\n\nEach variant maps directly to a standard SQL type. Use these via\n[`ColumnType::Simple`] when no length, precision, or scale is needed.", "oneOf": [ { "const": "small_int", From 325cedcf0a9b6c6f50432e6126e6964424709ec3 Mon Sep 17 00:00:00 2001 From: JaeHyunAn <98042706+yyuneu@users.noreply.github.com> Date: Sun, 20 Sep 2026 15:34:59 +0900 Subject: [PATCH 3/6] =?UTF-8?q?refactor(exporter):=20=EB=B0=B1=EC=97=94?= =?UTF-8?q?=EB=93=9C=20=EA=B3=B5=EC=9A=A9=20=EC=8A=A4=EC=BA=94=C2=B7?= =?UTF-8?q?=EB=A6=AC=ED=84=B0=EB=9F=B4=20=ED=97=AC=ED=8D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/constraint_scan.rs | 113 +++++++++++------- .../vespertide-exporter/src/drizzle/enums.rs | 6 +- .../vespertide-exporter/src/drizzle/render.rs | 37 +++--- .../vespertide-exporter/src/drizzle/types.rs | 8 +- crates/vespertide-exporter/src/enum_scan.rs | 22 +++- .../vespertide-exporter/src/seaorm/render.rs | 3 +- .../src/sqlalchemy/render.rs | 12 +- .../src/sqlmodel/render.rs | 17 ++- .../vespertide-exporter/src/utils/common.rs | 89 ++++++++++++++ .../vespertide-exporter/src/utils/python.rs | 28 ----- .../src/utils/typescript.rs | 37 ------ 11 files changed, 219 insertions(+), 153 deletions(-) diff --git a/crates/vespertide-exporter/src/constraint_scan.rs b/crates/vespertide-exporter/src/constraint_scan.rs index 0eacc4cf..1c5dc900 100644 --- a/crates/vespertide-exporter/src/constraint_scan.rs +++ b/crates/vespertide-exporter/src/constraint_scan.rs @@ -8,7 +8,7 @@ use std::collections::{HashMap, HashSet}; -use vespertide_core::{ColumnName, TableConstraint, TableDef}; +use vespertide_core::{ColumnName, ReferenceAction, TableConstraint, TableDef}; use vespertide_naming::{infer_relation_field_name, to_pascal_case}; /// Collect the column names from every single-column constraint that `extract` @@ -73,20 +73,30 @@ pub(crate) fn single_column_indexes(constraints: &[TableConstraint]) -> HashSet< }) } -/// Map each single-column foreign key's column name to its -/// `(ref_table, ref_col)` target. Only foreign keys with exactly one owning -/// column and one referenced column are included; composite FKs are skipped. +/// A single-column foreign key's target and referential actions. +pub(crate) struct FkDetails<'a> { + pub(crate) ref_table: &'a str, + pub(crate) ref_column: &'a str, + pub(crate) on_delete: Option<&'a ReferenceAction>, + pub(crate) on_update: Option<&'a ReferenceAction>, +} + +/// Map each single-column foreign key's column name to its target and +/// referential actions. Only foreign keys with exactly one owning column and +/// one referenced column are included; composite FKs are skipped. /// /// Lookup-only, ordering unused. -pub(crate) fn single_column_fk_targets( +pub(crate) fn single_column_fk_details( constraints: &[TableConstraint], -) -> HashMap<&str, (&str, &str)> { +) -> HashMap<&str, FkDetails<'_>> { let mut map = HashMap::new(); for constraint in constraints { if let TableConstraint::ForeignKey { columns, ref_table, ref_columns, + on_delete, + on_update, .. } = constraint && columns.len() == 1 @@ -94,7 +104,12 @@ pub(crate) fn single_column_fk_targets( { map.insert( columns[0].as_str(), - (ref_table.as_str(), ref_columns[0].as_str()), + FkDetails { + ref_table: ref_table.as_str(), + ref_column: ref_columns[0].as_str(), + on_delete: on_delete.as_ref(), + on_update: on_update.as_ref(), + }, ); } } @@ -159,6 +174,10 @@ pub(crate) fn fk_relation_names(table: &TableDef) -> HashMap { /// name both ends must agree on — is the same everywhere. pub(crate) struct BackRelation { pub(crate) source_table: String, + pub(crate) fk_columns: Vec, + pub(crate) ref_columns: Vec, + pub(crate) on_delete: Option, + pub(crate) on_update: Option, pub(crate) rel_segment: String, pub(crate) is_one_to_one: bool, pub(crate) relation_name: Option, @@ -170,65 +189,67 @@ pub(crate) fn collect_back_relations(target_table: &str, schema: &[TableDef]) -> let mut result = Vec::new(); for source in schema { - let fks_to_target: Vec<(usize, &[ColumnName])> = source + let fks_to_target = source .constraints .iter() - .enumerate() - .filter_map(|(idx, c)| { - if let TableConstraint::ForeignKey { - columns, ref_table, .. - } = c - { - if ref_table.as_str() == target_table { - Some((idx, columns.as_slice())) - } else { - None - } - } else { - None - } + .filter(|c| { + matches!(c, TableConstraint::ForeignKey { ref_table, .. } + if ref_table.as_str() == target_table) }) - .collect(); + .count(); - if fks_to_target.is_empty() { + if fks_to_target == 0 { continue; } let source_relation_names = fk_relation_names(source); - let multi_fk = fks_to_target.len() > 1; + let multi_fk = fks_to_target > 1; let is_self_ref = source.name.as_str() == target_table; - for (constraint_idx, fk_cols) in &fks_to_target { - let is_one_to_one = if let [fk_col] = fk_cols { - source.constraints.iter().any(|c| { - matches!(c, TableConstraint::Unique { columns, .. } - if columns.len() == 1 && columns[0] == *fk_col) - }) - } else { - // A composite FK is one-to-one when the source can hold at - // most one row per target key: its FK columns are exactly its - // own PK, or a composite unique covers exactly that set. - let fk_set: HashSet<&str> = fk_cols.iter().map(ColumnName::as_str).collect(); - let pk_cols = primary_key(&source.constraints) - .map(TableConstraint::columns) - .unwrap_or_default(); - pk_cols.len() == fk_set.len() && pk_cols.iter().all(|c| fk_set.contains(c.as_str())) - || source.constraints.iter().any(|c| { - matches!(c, TableConstraint::Unique { columns, .. } - if columns.len() == fk_set.len() - && columns.iter().all(|col| fk_set.contains(col.as_str()))) - }) + for (constraint_idx, constraint) in source.constraints.iter().enumerate() { + let TableConstraint::ForeignKey { + columns: fk_cols, + ref_table, + ref_columns, + on_delete, + on_update, + .. + } = constraint + else { + continue; }; + if ref_table.as_str() != target_table { + continue; + } + + // A key is one-to-one when the source can hold at most one row + // per target key: its FK columns are exactly its own PK, or a + // unique covers exactly that set. + let fk_set: HashSet<&str> = fk_cols.iter().map(ColumnName::as_str).collect(); + let pk_cols = primary_key(&source.constraints) + .map(TableConstraint::columns) + .unwrap_or_default(); + let is_one_to_one = pk_cols.len() == fk_set.len() + && pk_cols.iter().all(|c| fk_set.contains(c.as_str())) + || source.constraints.iter().any(|c| { + matches!(c, TableConstraint::Unique { columns, .. } + if columns.len() == fk_set.len() + && columns.iter().all(|col| fk_set.contains(col.as_str()))) + }); let rel_segment = relation_segment(fk_cols); let relation_name = if multi_fk || is_self_ref { - source_relation_names.get(constraint_idx).cloned() + source_relation_names.get(&constraint_idx).cloned() } else { None }; result.push(BackRelation { source_table: source.name.as_str().to_string(), + fk_columns: fk_cols.iter().map(ToString::to_string).collect(), + ref_columns: ref_columns.iter().map(ToString::to_string).collect(), + on_delete: on_delete.clone(), + on_update: on_update.clone(), rel_segment, is_one_to_one, relation_name, diff --git a/crates/vespertide-exporter/src/drizzle/enums.rs b/crates/vespertide-exporter/src/drizzle/enums.rs index a755dab7..f6e145fb 100644 --- a/crates/vespertide-exporter/src/drizzle/enums.rs +++ b/crates/vespertide-exporter/src/drizzle/enums.rs @@ -13,7 +13,7 @@ use vespertide_naming::build_enum_type_name; -use crate::utils::typescript::ts_string; +use crate::utils::common::string_literal; /// The natural `const` binding an enum declaration and its columns share, /// derived from the database type name so the two stay recognisably paired. @@ -31,10 +31,10 @@ pub(super) fn enum_const_name(table: &str, enum_name: &str) -> String { /// them to PostgreSQL as written, so there is no variant-name normalization /// and nothing that would need a `@map` equivalent. pub(super) fn render_enum_decl(const_name: &str, db_name: &str, values: &[String]) -> String { - let variants: Vec = values.iter().map(|v| ts_string(v)).collect(); + let variants: Vec = values.iter().map(|v| string_literal(v)).collect(); format!( "export const {const_name} = pgEnum({}, [{}]);", - ts_string(db_name), + string_literal(db_name), variants.join(", ") ) } diff --git a/crates/vespertide-exporter/src/drizzle/render.rs b/crates/vespertide-exporter/src/drizzle/render.rs index 08eecc71..f468e780 100644 --- a/crates/vespertide-exporter/src/drizzle/render.rs +++ b/crates/vespertide-exporter/src/drizzle/render.rs @@ -15,8 +15,7 @@ use super::bindings::FileBindings; use super::types::column_ctor; use super::{DrizzleDialect, Imports, js_name}; use crate::constraint_scan::{collect_back_relations, fk_relation_names, relation_segment}; -use crate::utils::common::{claim_field_name, unquote}; -use crate::utils::typescript::ts_string; +use crate::utils::common::{claim_field_name, integer_enum_variant_value, string_literal, unquote}; // ─── Constraint lookups ────────────────────────────────────────────────────── @@ -108,11 +107,17 @@ pub(super) fn render_table( // stores no name and its kit compares by columns alone. let name_field = match dialect { DrizzleDialect::Pg => { - format!("name: {}, ", ts_string(&format!("{}_pkey", table.name))) + format!( + "name: {}, ", + string_literal(&format!("{}_pkey", table.name)) + ) } DrizzleDialect::Mysql => { let joined = pk_columns.join("_"); - format!("name: {}, ", ts_string(&format!("{}_{joined}", table.name))) + format!( + "name: {}, ", + string_literal(&format!("{}_{joined}", table.name)) + ) } DrizzleDialect::Sqlite => String::new(), }; @@ -174,7 +179,7 @@ pub(super) fn render_table( // so the model does too. constraint_lines.push(format!( " check({}, sql`{}`),", - ts_string(name), + string_literal(name), escape_backtick(expr) )); } @@ -200,7 +205,7 @@ pub(super) fn render_table( "export const {} = {}({}, {{", bindings.table_const(&table.name), dialect.table_fn(), - ts_string(&table.name) + string_literal(&table.name) )); lines.extend(col_lines); if constraint_lines.is_empty() { @@ -247,7 +252,7 @@ fn column_refs>(columns: &[T]) -> String { fn table_level_entry(builder: &str, name: &str, columns: &[ColumnName]) -> String { format!( " {builder}({}).on({}),", - ts_string(name), + string_literal(name), column_refs(columns) ) } @@ -294,7 +299,7 @@ fn foreign_key_entry( .join(", ") }; - let name_field = name.map_or_else(String::new, |n| format!(", name: {}", ts_string(n))); + let name_field = name.map_or_else(String::new, |n| format!(", name: {}", string_literal(n))); let mut parts = vec![format!( " foreignKey({{ columns: [{}], foreignColumns: [{foreign_cols}]{name_field} }})", column_refs(columns) @@ -302,13 +307,13 @@ fn foreign_key_entry( if let Some(action) = on_delete { parts.push(format!( ".onDelete({})", - ts_string(reference_action_to_drizzle(action)) + string_literal(reference_action_to_drizzle(action)) )); } if let Some(action) = on_update { parts.push(format!( ".onUpdate({})", - ts_string(reference_action_to_drizzle(action)) + string_literal(reference_action_to_drizzle(action)) )); } parts.push(",".to_string()); @@ -393,7 +398,7 @@ pub(super) fn render_relations_block( .is_some_and(|n| *n > 1) || ref_table.as_str() == table.name.as_str(); if ambiguous && let Some(name) = relation_names.get(&constraint_idx) { - opts.push(format!("relationName: {}", ts_string(name))); + opts.push(format!("relationName: {}", string_literal(name))); } rel_lines.push(format!( @@ -411,7 +416,7 @@ pub(super) fn render_relations_block( }; let field = claim_field_name(preferred, &mut field_names); let opts = br.relation_name.as_ref().map_or_else(String::new, |n| { - format!(", {{ relationName: {} }}", ts_string(n)) + format!(", {{ relationName: {} }}", string_literal(n)) }); let builder = if br.is_one_to_one { "one" } else { "many" }; rel_lines.push(format!(" {field}: {builder}({source}{opts}),")); @@ -542,7 +547,7 @@ pub(super) fn default_chain( } else { inner.to_string() }; - return DefaultChain::literal(format!(".default({})", ts_string(&value))); + return DefaultChain::literal(format!(".default({})", string_literal(&value))); } if default_sql.parse::().is_ok() { @@ -553,7 +558,7 @@ pub(super) fn default_chain( ColumnType::Complex(ComplexColumnType::Numeric { .. }) ); return DefaultChain::literal(if numeric_col { - format!(".default({})", ts_string(default_sql)) + format!(".default({})", string_literal(default_sql)) } else { format!(".default({default_sql})") }); @@ -564,9 +569,9 @@ pub(super) fn default_chain( values: EnumValues::Integer(variants), .. }) = col_type - && let Some(variant) = variants.iter().find(|v| v.name == default_sql) + && let Some(value) = integer_enum_variant_value(variants, default_sql) { - return DefaultChain::literal(format!(".default({})", variant.value)); + return DefaultChain::literal(format!(".default({value})")); } // A bare keyword such as `CURRENT_USER`. diff --git a/crates/vespertide-exporter/src/drizzle/types.rs b/crates/vespertide-exporter/src/drizzle/types.rs index c0b04f94..e6f55f33 100644 --- a/crates/vespertide-exporter/src/drizzle/types.rs +++ b/crates/vespertide-exporter/src/drizzle/types.rs @@ -11,7 +11,7 @@ use vespertide_core::schema::column::{ use super::bindings::FileBindings; use super::{DrizzleDialect, js_name}; -use crate::utils::typescript::ts_string; +use crate::utils::common::string_literal; /// One resolved Drizzle column constructor. pub(super) struct ColumnCtor { @@ -34,7 +34,7 @@ impl ColumnCtor { format!( "{}({}{}){}", self.symbol, - ts_string(col_db), + string_literal(col_db), self.args, self.note ) @@ -70,7 +70,7 @@ fn widened(symbol: &str, args: String, source: &str) -> ColumnCtor { /// `["draft", "published"]` — the variant list MySQL and SQLite inline into the /// column, since neither declares an enum type separately. fn enum_value_list(values: &[String]) -> String { - let items: Vec = values.iter().map(|v| ts_string(v)).collect(); + let items: Vec = values.iter().map(|v| string_literal(v)).collect(); format!("[{}]", items.join(", ")) } @@ -115,7 +115,7 @@ pub(super) fn render_custom_type_decl(decl: &CustomTypeDecl, const_name: &str) - format!( "const {const_name} = customType<{{ data: {} }}>({{ dataType() {{ return {}; }} }});", decl.ts_data, - ts_string(&decl.data_type) + string_literal(&decl.data_type) ) } diff --git a/crates/vespertide-exporter/src/enum_scan.rs b/crates/vespertide-exporter/src/enum_scan.rs index 461b88c1..9da1a084 100644 --- a/crates/vespertide-exporter/src/enum_scan.rs +++ b/crates/vespertide-exporter/src/enum_scan.rs @@ -1,10 +1,11 @@ -//! Shared enum-column scan for the single-file ORM renderers. +//! Shared enum-column scans for the renderers that put a whole schema into +//! one scope. //! -//! Backends that write one file per table get enum scoping for free; Prisma -//! and Drizzle emit one file for the whole schema and both start from the same -//! per-table scan. What they do with it differs — Prisma deduplicates -//! identifiers globally (see `prisma::enums`), Drizzle table-prefixes every -//! type — so only the scan itself lives here. +//! Backends that write one file per table get enum scoping for free; Prisma, +//! Drizzle and GORM do not, and all start from the same per-table scan. What +//! they do with it differs — Prisma deduplicates identifiers globally (see +//! `prisma::enums`), Drizzle table-prefixes every type, GORM claims them in +//! the package's one scope (see `scope_names`). use vespertide_core::TableDef; use vespertide_core::schema::column::{ColumnType, ComplexColumnType, EnumValues}; @@ -24,3 +25,12 @@ pub(crate) fn collect_table_enums(table: &TableDef) -> Vec<(&str, &EnumValues)> } result } + +/// An enum's variant names in declaration order: the values of a string +/// enum, the member names of an integer one. +pub(crate) fn variant_names(values: &EnumValues) -> Vec<&str> { + match values { + EnumValues::String(values) => values.iter().map(String::as_str).collect(), + EnumValues::Integer(values) => values.iter().map(|v| v.name.as_str()).collect(), + } +} diff --git a/crates/vespertide-exporter/src/seaorm/render.rs b/crates/vespertide-exporter/src/seaorm/render.rs index 6c81aa02..7272d47c 100644 --- a/crates/vespertide-exporter/src/seaorm/render.rs +++ b/crates/vespertide-exporter/src/seaorm/render.rs @@ -9,6 +9,7 @@ use super::relations::{ relation_field_defs_with_schema, render_self_ref_link_helpers, render_self_ref_query_helpers, }; use super::types::{column_type_supports_eq, format_default_value}; +use crate::utils::common::is_jsonb_custom_type; /// Render a single table into `SeaORM` entity code with schema context, configuration, /// and module path mappings for correct cross-directory relation paths. @@ -218,7 +219,7 @@ pub(super) fn render_column( } // JSONB custom type should use Json rust type ColumnType::Complex(ComplexColumnType::Custom { custom_type }) - if custom_type.eq_ignore_ascii_case("JSONB") => + if is_jsonb_custom_type(custom_type) => { if column.nullable { "Option".to_string() diff --git a/crates/vespertide-exporter/src/sqlalchemy/render.rs b/crates/vespertide-exporter/src/sqlalchemy/render.rs index 442c1781..6eea1476 100644 --- a/crates/vespertide-exporter/src/sqlalchemy/render.rs +++ b/crates/vespertide-exporter/src/sqlalchemy/render.rs @@ -1,10 +1,10 @@ use super::enums::render_enum; use super::types::{UsedTypes, column_type_to_python, column_type_to_sqlalchemy}; +use crate::constraint_scan::FkDetails; use crate::parallel_config::{ PYTHON_EXPORT_PAR_TABLE_MIN_LEN, SQLALCHEMY_EXPORT_PAR_TABLE_THRESHOLD, }; -use crate::utils::common::{join_qualified_refs, join_quoted, push_attr}; -use crate::utils::python::collect_composite_fks; +use crate::utils::common::{collect_composite_fks, join_qualified_refs, join_quoted, push_attr}; use rayon::prelude::*; use vespertide_core::schema::column::{ColumnType, ComplexColumnType, EnumValues}; use vespertide_core::schema::constraint::TableConstraint; @@ -76,7 +76,7 @@ fn render_entity_part(table: &TableDef, used_types: &mut UsedTypes<'static>) -> // Collect single-column foreign key targets once; the import flag below and // the per-column render lookups both read from this single scan. - let fk_info = crate::constraint_scan::single_column_fk_targets(&table.constraints); + let fk_info = crate::constraint_scan::single_column_fk_details(&table.constraints); // Check for single-column foreign keys if !fk_info.is_empty() { @@ -272,7 +272,7 @@ fn render_column( col: &ColumnDef, is_pk: bool, is_unique: bool, - fk_info: Option<&(&str, &str)>, + fk_info: Option<&FkDetails>, ) { // Add column comment if let Some(ref comment) = col.comment { @@ -291,10 +291,10 @@ fn render_column( push_attr(&mut attrs, &sa_type); // Foreign key - if let Some((ref_table, ref_col)) = fk_info { + if let Some(fk) = fk_info { push_attr( &mut attrs, - &format!("ForeignKey(\"{ref_table}.{ref_col}\")"), + &format!("ForeignKey(\"{}.{}\")", fk.ref_table, fk.ref_column), ); } diff --git a/crates/vespertide-exporter/src/sqlmodel/render.rs b/crates/vespertide-exporter/src/sqlmodel/render.rs index 8fb6e5b2..a58f6f61 100644 --- a/crates/vespertide-exporter/src/sqlmodel/render.rs +++ b/crates/vespertide-exporter/src/sqlmodel/render.rs @@ -1,10 +1,12 @@ use rayon::prelude::*; +use crate::constraint_scan::FkDetails; use crate::parallel_config::{ PYTHON_EXPORT_PAR_TABLE_MIN_LEN, SQLMODEL_EXPORT_PAR_TABLE_THRESHOLD, }; -use crate::utils::common::{join_qualified_refs, join_quoted, unquote}; -use crate::utils::python::{CompositeFk, collect_composite_fks}; +use crate::utils::common::{ + CompositeFk, collect_composite_fks, join_qualified_refs, join_quoted, unquote, +}; use vespertide_core::schema::column::{ColumnType, ComplexColumnType, EnumValues}; use vespertide_core::schema::constraint::TableConstraint; use vespertide_core::{ColumnDef, TableDef}; @@ -255,7 +257,7 @@ fn render_entity_body(table: &TableDef, composite_fks: &[CompositeFk<'_>]) -> Ve let indexed_columns = crate::constraint_scan::single_column_indexes(&table.constraints); // Collect foreign key info; lookup-only, ordering unused. - let fk_info = crate::constraint_scan::single_column_fk_targets(&table.constraints); + let fk_info = crate::constraint_scan::single_column_fk_details(&table.constraints); // Render columns for col in &table.columns { @@ -348,7 +350,7 @@ pub(super) fn render_column( is_pk: bool, is_unique: bool, is_indexed: bool, - fk_info: Option<&(&str, &str)>, + fk_info: Option<&FkDetails>, ) { // Add column comment if let Some(ref comment) = col.comment { @@ -395,8 +397,11 @@ pub(super) fn render_column( } // Foreign key - if let Some((ref_table, ref_col)) = fk_info { - field_args.push(format!("foreign_key=\"{ref_table}.{ref_col}\"")); + if let Some(fk) = fk_info { + field_args.push(format!( + "foreign_key=\"{}.{}\"", + fk.ref_table, fk.ref_column + )); } // Unique diff --git a/crates/vespertide-exporter/src/utils/common.rs b/crates/vespertide-exporter/src/utils/common.rs index 48d47bec..2588ee14 100644 --- a/crates/vespertide-exporter/src/utils/common.rs +++ b/crates/vespertide-exporter/src/utils/common.rs @@ -1,5 +1,7 @@ //! Cross-language helpers shared by every ORM exporter backend. +use vespertide_core::{NumValue, ReferenceAction, TableConstraint, TableDef}; + /// Join items as a double-quoted, comma-separated list: `"a", "b", "c"`. /// /// Consolidates the quoted-comma-join pattern previously copy-pasted across @@ -70,6 +72,32 @@ pub(crate) fn join_qualified_refs(ref_table: &str, ref_cols: &[&str]) -> String out } +/// Quote `value` as a double-quoted string literal. +/// +/// Backslashes, quotes and the line terminators are escaped so a database name +/// or enum value containing any of them cannot end the literal early. The +/// escapes are the ones TypeScript and Go share, so every literal the Drizzle +/// and GORM renderers emit — table names, column names, enum values — goes +/// through here. +pub(crate) fn string_literal(value: &str) -> String { + let mut out = String::with_capacity(value.len() + 2); + out.push('"'); + for ch in value.chars() { + match ch { + '\\' | '"' => { + out.push('\\'); + out.push(ch); + } + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + _ => out.push(ch), + } + } + out.push('"'); + out +} + /// Strip one matching pair of surrounding quotes from a SQL literal. /// /// Only an outer pair is removed, so quotes *inside* the literal survive: @@ -88,6 +116,55 @@ pub(crate) fn unquote(s: &str) -> &str { s } +/// The stored value of the integer-enum variant named `name`, if there is one. +/// A model may write an integer enum's default as the variant name; the column +/// stores the value. +pub(crate) fn integer_enum_variant_value(variants: &[NumValue], name: &str) -> Option { + variants.iter().find(|v| v.name == name).map(|v| v.value) +} + +/// `JSONB` is the one custom column type the backends map to a native JSON +/// type instead of a plain string; the model may spell it in any case. +pub(crate) fn is_jsonb_custom_type(custom_type: &str) -> bool { + custom_type.eq_ignore_ascii_case("JSONB") +} + +/// A composite (multi-column) foreign key: its owning columns, target and +/// referential actions. Backends with no native composite relation +/// (SQLAlchemy, SQLModel) surface it as a comment; GORM renders it as +/// a relation field with comma-separated `foreignKey`/`references`. +pub(crate) struct CompositeFk<'a> { + pub(crate) local_cols: Vec<&'a str>, + pub(crate) ref_table: &'a str, + pub(crate) ref_cols: Vec<&'a str>, + pub(crate) on_delete: Option<&'a ReferenceAction>, + pub(crate) on_update: Option<&'a ReferenceAction>, +} + +pub(crate) fn collect_composite_fks(table: &TableDef) -> Vec> { + table + .constraints + .iter() + .filter_map(|constraint| match constraint { + TableConstraint::ForeignKey { + columns, + ref_table, + ref_columns, + on_delete, + on_update, + .. + } if columns.len() > 1 && columns.len() == ref_columns.len() => Some(CompositeFk { + local_cols: columns.iter().map(AsRef::as_ref).collect(), + ref_table: ref_table.as_str(), + ref_cols: ref_columns.iter().map(AsRef::as_ref).collect(), + on_delete: on_delete.as_ref(), + on_update: on_update.as_ref(), + }), + _ => None, + }) + .collect() +} + /// Claim a relation field name, recording it in `taken` so later fields /// avoid it. Seed `taken` with the table's column field names first — relation /// names are derived from column/table names, so a relation must not take a @@ -228,4 +305,16 @@ mod tests { fn unquote_removes_only_a_matching_outer_pair(#[case] input: &str, #[case] expected: &str) { assert_eq!(unquote(input), expected); } + + #[rstest] + #[case::plain("users", r#""users""#)] + #[case::double_quote("say \"hi\"", r#""say \"hi\"""#)] + #[case::backslash("back\\slash", r#""back\\slash""#)] + #[case::newline("two\nlines", r#""two\nlines""#)] + #[case::carriage_return("a\rb", r#""a\rb""#)] + #[case::tab("a\tb", r#""a\tb""#)] + #[case::empty("", r#""""#)] + fn string_literal_escapes_literal_terminators(#[case] input: &str, #[case] expected: &str) { + assert_eq!(string_literal(input), expected); + } } diff --git a/crates/vespertide-exporter/src/utils/python.rs b/crates/vespertide-exporter/src/utils/python.rs index e145f668..03b7fa35 100644 --- a/crates/vespertide-exporter/src/utils/python.rs +++ b/crates/vespertide-exporter/src/utils/python.rs @@ -1,8 +1,6 @@ -use vespertide_core::TableDef; use vespertide_core::schema::column::{ ColumnType, ComplexColumnType, EnumValues, SimpleColumnType, }; -use vespertide_core::schema::constraint::TableConstraint; use vespertide_naming::{IdentifierStart, sanitize_identifier, to_screaming_snake_case}; @@ -85,29 +83,3 @@ pub(crate) fn column_type_to_python(col_type: &ColumnType, nullable: bool) -> St base.to_string() } } - -pub(crate) struct CompositeFk<'a> { - pub local_cols: Vec<&'a str>, - pub ref_table: &'a str, - pub ref_cols: Vec<&'a str>, -} - -pub(crate) fn collect_composite_fks(table: &TableDef) -> Vec> { - table - .constraints - .iter() - .filter_map(|constraint| match constraint { - TableConstraint::ForeignKey { - columns, - ref_table, - ref_columns, - .. - } if columns.len() > 1 && columns.len() == ref_columns.len() => Some(CompositeFk { - local_cols: columns.iter().map(AsRef::as_ref).collect(), - ref_table: ref_table.as_str(), - ref_cols: ref_columns.iter().map(AsRef::as_ref).collect(), - }), - _ => None, - }) - .collect() -} diff --git a/crates/vespertide-exporter/src/utils/typescript.rs b/crates/vespertide-exporter/src/utils/typescript.rs index 9270609d..713fb0cd 100644 --- a/crates/vespertide-exporter/src/utils/typescript.rs +++ b/crates/vespertide-exporter/src/utils/typescript.rs @@ -86,31 +86,6 @@ pub(crate) fn ts_binding(name: &str) -> String { sanitized } -/// Quote `value` as a double-quoted TypeScript string literal. -/// -/// Backslashes, quotes and the line terminators are escaped so a database name -/// or enum value containing any of them cannot end the literal early. Every -/// literal Drizzle emits — table names, column names, enum values — goes -/// through here. -pub(crate) fn ts_string(value: &str) -> String { - let mut out = String::with_capacity(value.len() + 2); - out.push('"'); - for ch in value.chars() { - match ch { - '\\' | '"' => { - out.push('\\'); - out.push(ch); - } - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - _ => out.push(ch), - } - } - out.push('"'); - out -} - #[cfg(test)] mod tests { use super::*; @@ -130,16 +105,4 @@ mod tests { fn ts_binding_escapes_digits_and_reserved_words(#[case] input: &str, #[case] expected: &str) { assert_eq!(ts_binding(input), expected); } - - #[rstest] - #[case::plain("users", r#""users""#)] - #[case::double_quote("say \"hi\"", r#""say \"hi\"""#)] - #[case::backslash("back\\slash", r#""back\\slash""#)] - #[case::newline("two\nlines", r#""two\nlines""#)] - #[case::carriage_return("a\rb", r#""a\rb""#)] - #[case::tab("a\tb", r#""a\tb""#)] - #[case::empty("", r#""""#)] - fn ts_string_escapes_literal_terminators(#[case] input: &str, #[case] expected: &str) { - assert_eq!(ts_string(input), expected); - } } From d51618287200eb51af073dc24afa061e182d65d1 Mon Sep 17 00:00:00 2001 From: JaeHyunAn <98042706+yyuneu@users.noreply.github.com> Date: Sun, 20 Sep 2026 15:35:04 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat(exporter):=20GORM=20=EB=B0=B1=EC=97=94?= =?UTF-8?q?=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../benches/codegen_benchmarks.rs | 19 +- crates/vespertide-exporter/src/gorm/enums.rs | 62 + crates/vespertide-exporter/src/gorm/mod.rs | 1061 ++------------- crates/vespertide-exporter/src/gorm/render.rs | 840 ++++++++++++ .../vespertide-exporter/src/gorm/tests/mod.rs | 1137 ----------------- .../src/gorm/tests/relations.rs | 143 --- ...porter__gorm__tests__all_simple_types.snap | 36 - ...de_exporter__gorm__tests__basic_table.snap | 16 - ...r__gorm__tests__composite_pk_nullable.snap | 16 - ...gorm__tests__has_many_with_constraint.snap | 12 - ...__gorm__tests__server_default_skipped.snap | 17 - ...__gorm__tests__table_with_foreign_key.snap | 14 - ..._gorm__tests__table_with_integer_enum.snap | 20 - ..._gorm__tests__table_with_jsonb_column.snap | 17 - ...__gorm__tests__table_with_string_enum.snap | 20 - crates/vespertide-exporter/src/gorm/types.rs | 169 +++ crates/vespertide-exporter/src/lib.rs | 5 +- crates/vespertide-exporter/src/orm.rs | 12 +- .../vespertide-exporter/src/python_naming.rs | 16 +- crates/vespertide-exporter/src/scope_names.rs | 258 ++++ .../src/tests/fixtures/identifiers.rs | 115 ++ .../src/tests/fixtures/mod.rs | 23 +- .../src/tests/fixtures/reference_actions.rs | 86 ++ .../src/tests/fixtures/schemas.rs | 20 + crates/vespertide-exporter/src/tests/mod.rs | 68 +- ..._types_snapshot@all_simple_types_Gorm.snap | 36 + ...ngle_pk_snapshot@basic_single_pk_Gorm.snap | 12 + ...hot@basic_table_with_description_Gorm.snap | 16 + ...ions_snapshot@binding_collisions_Gorm.snap | 34 + ...lex_types_snapshot@complex_types_Gorm.snap | 19 + ...s_snapshot@composite_constraints_Gorm.snap | 15 + ...n_snapshot@composite_fk_relation_Gorm.snap | 23 + ...e_index_snapshot@composite_index_Gorm.snap | 13 + ...mposite_pk_snapshot@composite_pk_Gorm.snap | 12 + ...y_snapshot@composite_primary_key_Gorm.snap | 13 + ...shot@composite_unique_constraint_Gorm.snap | 13 + ...unique_snapshot@composite_unique_Gorm.snap | 13 + ...ests__defaults_snapshot@defaults_Gorm.snap | 14 + ...s_snapshot@enum_multiple_columns_Gorm.snap | 29 + ..._name_shared_across_tables_Drizzle_pg.snap | 17 + ...t@enum_name_shared_across_tables_Gorm.snap | 33 + ...ot@enum_name_shared_across_tables_Jpa.snap | 49 + ...enum_name_shared_across_tables_Prisma.snap | 27 + ...enum_name_shared_across_tables_SeaOrm.snap | 54 + ..._name_shared_across_tables_SqlAlchemy.snap | 31 + ...um_name_shared_across_tables_SqlModel.snap | 30 + ...enum_shared_snapshot@enum_shared_Gorm.snap | 21 + ...ues_snapshot@enum_special_values_Gorm.snap | 21 + ...fault_snapshot@enum_with_default_Gorm.snap | 22 + ...t_snapshot@false_boolean_default_Gorm.snap | 12 + ...@fk_names_collide_after_id_strip_Gorm.snap | 24 + ..._with_comment_and_auto_increment_Gorm.snap | 14 + ...ts__inline_pk_snapshot@inline_pk_Gorm.snap | 16 + ...t@integer_enum_all_variant_types_Gorm.snap | 21 + ...apshot@integer_enum_with_default_Gorm.snap | 19 + ...nteger_enum_with_variant_default_Gorm.snap | 19 + ...on_default_snapshot@json_default_Gorm.snap | 16 + ..._type_snapshot@jsonb_custom_type_Gorm.snap | 18 + ...cription_snapshot@no_description_Gorm.snap | 11 + ..._identifier_names_in_constraints_Gorm.snap | 14 + ...es_snapshot@non_identifier_names_Gorm.snap | 15 + ...ot@non_identifier_relation_names_Gorm.snap | 24 + ...olumns_snapshot@nullable_columns_Gorm.snap | 13 + ...able_enum_snapshot@nullable_enum_Gorm.snap | 19 + ...e_snapshot@numeric_default_value_Gorm.snap | 16 + ...ther_snapshot@pk_and_fk_together_Gorm.snap | 24 + ...snapshot@reference_actions_Drizzle_pg.snap | 38 + ...tions_snapshot@reference_actions_Gorm.snap | 32 + ...ctions_snapshot@reference_actions_Jpa.snap | 57 + ...ons_snapshot@reference_actions_Prisma.snap | 30 + ...ons_snapshot@reference_actions_SeaOrm.snap | 60 + ...snapshot@reference_actions_SqlAlchemy.snap | 29 + ...s_snapshot@reference_actions_SqlModel.snap | 28 + ...pshot@relation_field_names_Drizzle_pg.snap | 64 + ...es_snapshot@relation_field_names_Gorm.snap | 52 + ...mes_snapshot@relation_field_names_Jpa.snap | 104 ++ ..._snapshot@relation_field_names_Prisma.snap | 51 + ..._snapshot@relation_field_names_SeaOrm.snap | 96 ++ ...pshot@relation_field_names_SqlAlchemy.snap | 49 + ...napshot@relation_field_names_SqlModel.snap | 49 + ...ot@relation_name_taken_by_column_Gorm.snap | 20 + ...posite_and_single_fk_same_target_Gorm.snap | 16 + ...ma_snapshots@composite_fk_parent_Gorm.snap | 14 + ...snapshots@dual_reverse_relations_Gorm.snap | 13 + ...a_snapshots@many_to_many_article_Gorm.snap | 12 + ...hots@many_to_many_missing_target_Gorm.snap | 12 + ...@many_to_many_multiple_junctions_Gorm.snap | 17 + ...hema_snapshots@many_to_many_user_Gorm.snap | 16 + ...snapshots@multiple_fk_same_table_Gorm.snap | 19 + ...shots@multiple_has_one_relations_Gorm.snap | 17 + ...shots@multiple_reverse_relations_Gorm.snap | 17 + ...ot_junction_fk_not_in_pk_another_Gorm.snap | 12 + ...@not_junction_fk_not_in_pk_other_Gorm.snap | 12 + ...snapshots@not_junction_single_pk_Gorm.snap | 12 + ..._to_one_shared_primary_key_Drizzle_pg.snap | 11 + ...ts@one_to_one_shared_primary_key_Gorm.snap | 16 + ...ots@one_to_one_shared_primary_key_Jpa.snap | 18 + ...@one_to_one_shared_primary_key_Prisma.snap | 10 + ...@one_to_one_shared_primary_key_SeaOrm.snap | 18 + ..._to_one_shared_primary_key_SqlAlchemy.snap | 16 + ...ne_to_one_shared_primary_key_SqlModel.snap | 15 + ...napshots@one_to_one_source_Drizzle_pg.snap | 15 + ...hema_snapshots@one_to_one_source_Gorm.snap | 17 + ...chema_snapshots@one_to_one_source_Jpa.snap | 22 + ...ma_snapshots@one_to_one_source_Prisma.snap | 11 + ...ma_snapshots@one_to_one_source_SeaOrm.snap | 20 + ...napshots@one_to_one_source_SqlAlchemy.snap | 17 + ..._snapshots@one_to_one_source_SqlModel.snap | 16 + ...apshots@triple_reverse_relations_Gorm.snap | 14 + ...ith_schema_snapshots@username_fk_Gorm.snap | 17 + ...apshot@reserved_word_identifiers_Gorm.snap | 13 + ..._fk_snapshot@self_referencing_fk_Gorm.snap | 13 + ...snapshot@semicolon_default_Drizzle_pg.snap | 9 + ...fault_snapshot@semicolon_default_Gorm.snap | 13 + ...efault_snapshot@semicolon_default_Jpa.snap | 23 + ...ult_snapshot@semicolon_default_Prisma.snap | 11 + ...ult_snapshot@semicolon_default_SeaOrm.snap | 19 + ...snapshot@semicolon_default_SqlAlchemy.snap | 17 + ...t_snapshot@semicolon_default_SqlModel.snap | 16 + ...@server_default_and_true_boolean_Gorm.snap | 19 + ...efaults_snapshot@server_defaults_Gorm.snap | 18 + ...ot@small_multi_schema_sequential_Gorm.snap | 22 + ..._default_snapshot@string_default_Gorm.snap | 12 + ...level_pk_snapshot@table_level_pk_Gorm.snap | 17 + ..._check_snapshot@table_with_check_Gorm.snap | 12 + ...snapshot@table_with_composite_fk_Gorm.snap | 15 + ...th_enum_snapshot@table_with_enum_Gorm.snap | 20 + ...e_with_fk_snapshot@table_with_fk_Gorm.snap | 14 + ...exes_snapshot@table_with_indexes_Gorm.snap | 17 + ...snapshot@table_with_integer_enum_Gorm.snap | 20 + ...exed_snapshot@unique_and_indexed_Gorm.snap | 15 + ...napshot@unknown_constant_default_Gorm.snap | 12 + ...napshot@unknown_function_default_Gorm.snap | 12 + ...snapshot@unnamed_composite_index_Gorm.snap | 13 + ...napshot@unnamed_composite_unique_Gorm.snap | 13 + ...napshot@unnamed_index_and_unique_Gorm.snap | 17 + .../tests/parallel_consolidated.rs | 4 + 137 files changed, 4254 insertions(+), 2417 deletions(-) create mode 100644 crates/vespertide-exporter/src/gorm/enums.rs create mode 100644 crates/vespertide-exporter/src/gorm/render.rs delete mode 100644 crates/vespertide-exporter/src/gorm/tests/mod.rs delete mode 100644 crates/vespertide-exporter/src/gorm/tests/relations.rs delete mode 100644 crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__all_simple_types.snap delete mode 100644 crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__basic_table.snap delete mode 100644 crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__composite_pk_nullable.snap delete mode 100644 crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__has_many_with_constraint.snap delete mode 100644 crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__server_default_skipped.snap delete mode 100644 crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__table_with_foreign_key.snap delete mode 100644 crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__table_with_integer_enum.snap delete mode 100644 crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__table_with_jsonb_column.snap delete mode 100644 crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__table_with_string_enum.snap create mode 100644 crates/vespertide-exporter/src/gorm/types.rs create mode 100644 crates/vespertide-exporter/src/scope_names.rs create mode 100644 crates/vespertide-exporter/src/tests/fixtures/identifiers.rs create mode 100644 crates/vespertide-exporter/src/tests/fixtures/reference_actions.rs create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__all_simple_types_snapshot@all_simple_types_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_single_pk_snapshot@basic_single_pk_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_table_with_description_snapshot@basic_table_with_description_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__binding_collisions_snapshot@binding_collisions_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__complex_types_snapshot@complex_types_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_constraints_snapshot@composite_constraints_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_fk_relation_snapshot@composite_fk_relation_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_index_snapshot@composite_index_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_pk_snapshot@composite_pk_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_primary_key_snapshot@composite_primary_key_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_constraint_snapshot@composite_unique_constraint_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_snapshot@composite_unique_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__defaults_snapshot@defaults_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_multiple_columns_snapshot@enum_multiple_columns_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_Drizzle_pg.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_Jpa.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_SeaOrm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_SqlAlchemy.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_SqlModel.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_shared_snapshot@enum_shared_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_special_values_snapshot@enum_special_values_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_with_default_snapshot@enum_with_default_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__false_boolean_default_snapshot@false_boolean_default_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_names_collide_after_id_strip_snapshot@fk_names_collide_after_id_strip_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_with_comment_and_auto_increment_snapshot@fk_with_comment_and_auto_increment_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__inline_pk_snapshot@inline_pk_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_all_variant_types_snapshot@integer_enum_all_variant_types_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_default_snapshot@integer_enum_with_default_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_variant_default_snapshot@integer_enum_with_variant_default_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__json_default_snapshot@json_default_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__jsonb_custom_type_snapshot@jsonb_custom_type_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__no_description_snapshot@no_description_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_names_in_constraints_snapshot@non_identifier_names_in_constraints_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_names_snapshot@non_identifier_names_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_relation_names_snapshot@non_identifier_relation_names_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_columns_snapshot@nullable_columns_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_enum_snapshot@nullable_enum_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__numeric_default_value_snapshot@numeric_default_value_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__pk_and_fk_together_snapshot@pk_and_fk_together_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_Drizzle_pg.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_Jpa.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_SeaOrm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_SqlAlchemy.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_SqlModel.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_Drizzle_pg.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_Jpa.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_SeaOrm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_SqlAlchemy.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_SqlModel.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_name_taken_by_column_snapshot@relation_name_taken_by_column_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@composite_and_single_fk_same_target_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@composite_fk_parent_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@dual_reverse_relations_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_article_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_missing_target_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_multiple_junctions_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_user_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_fk_same_table_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_has_one_relations_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_reverse_relations_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_fk_not_in_pk_another_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_fk_not_in_pk_other_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_single_pk_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_Drizzle_pg.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_Jpa.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_SeaOrm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_SqlAlchemy.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_SqlModel.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_Drizzle_pg.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_Jpa.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_SeaOrm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_SqlAlchemy.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_SqlModel.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@triple_reverse_relations_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@username_fk_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reserved_word_identifiers_snapshot@reserved_word_identifiers_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__self_referencing_fk_snapshot@self_referencing_fk_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_Drizzle_pg.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_Jpa.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_SeaOrm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_SqlAlchemy.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_SqlModel.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_default_and_true_boolean_snapshot@server_default_and_true_boolean_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_defaults_snapshot@server_defaults_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__small_multi_schema_sequential_snapshot@small_multi_schema_sequential_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__string_default_snapshot@string_default_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_level_pk_snapshot@table_level_pk_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_check_snapshot@table_with_check_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_composite_fk_snapshot@table_with_composite_fk_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_enum_snapshot@table_with_enum_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_fk_snapshot@table_with_fk_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_indexes_snapshot@table_with_indexes_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_integer_enum_snapshot@table_with_integer_enum_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unique_and_indexed_snapshot@unique_and_indexed_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_constant_default_snapshot@unknown_constant_default_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_function_default_snapshot@unknown_function_default_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_index_snapshot@unnamed_composite_index_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_unique_snapshot@unnamed_composite_unique_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_index_and_unique_snapshot@unnamed_index_and_unique_Gorm.snap diff --git a/crates/vespertide-exporter/benches/codegen_benchmarks.rs b/crates/vespertide-exporter/benches/codegen_benchmarks.rs index a18f2cca..7c674cf7 100644 --- a/crates/vespertide-exporter/benches/codegen_benchmarks.rs +++ b/crates/vespertide-exporter/benches/codegen_benchmarks.rs @@ -9,8 +9,23 @@ use vespertide_core::{ }; use vespertide_exporter::{Orm, render_entity_with_schema}; -const ALL_ORMS: [Orm; 4] = [Orm::SeaOrm, Orm::SqlAlchemy, Orm::SqlModel, Orm::Jpa]; -const ENUM_ORMS: [Orm; 3] = [Orm::SeaOrm, Orm::SqlAlchemy, Orm::SqlModel]; +const ALL_ORMS: [Orm; 7] = [ + Orm::SeaOrm, + Orm::SqlAlchemy, + Orm::SqlModel, + Orm::Jpa, + Orm::Prisma, + Orm::Drizzle, + Orm::Gorm, +]; +const ENUM_ORMS: [Orm; 6] = [ + Orm::SeaOrm, + Orm::SqlAlchemy, + Orm::SqlModel, + Orm::Prisma, + Orm::Drizzle, + Orm::Gorm, +]; const FK_COLUMNS_PER_TABLE: usize = 20; fn simple_type(ty: SimpleColumnType) -> ColumnType { diff --git a/crates/vespertide-exporter/src/gorm/enums.rs b/crates/vespertide-exporter/src/gorm/enums.rs new file mode 100644 index 00000000..6ebcc74a --- /dev/null +++ b/crates/vespertide-exporter/src/gorm/enums.rs @@ -0,0 +1,62 @@ +use vespertide_core::schema::column::EnumValues; +use vespertide_naming::{IdentifierStart, sanitize_identifier}; + +use super::render::to_pascal_case; +use crate::utils::common::string_literal; + +/// `type_name` and `const_names` are the names claimed for this enum in the +/// package's scope, one constant per value in declaration order. +pub(super) fn render_enum( + lines: &mut Vec, + type_name: &str, + const_names: &[String], + values: &EnumValues, +) { + let mut rendered = match values { + EnumValues::String(_) => { + vec![ + format!("type {type_name} string"), + String::new(), + "const (".into(), + ] + } + EnumValues::Integer(_) => { + vec![ + format!("type {type_name} int"), + String::new(), + "const (".into(), + ] + } + }; + + match values { + EnumValues::String(vals) => { + for (val, const_name) in vals.iter().zip(const_names) { + rendered.push(format!( + " {const_name} {type_name} = {}", + string_literal(val) + )); + } + } + EnumValues::Integer(vals) => { + for (val, const_name) in vals.iter().zip(const_names) { + rendered.push(format!(" {const_name} {type_name} = {}", val.value)); + } + } + } + + rendered.push(")".into()); + lines.extend(rendered); +} + +/// The natural Go constant name for one enum member, before it is claimed in +/// the package's scope. The value is arbitrary text — `info-level` and +/// `1critical` are legal in the database — so it is escaped the same way +/// column names are. The `type_name` prefix already supplies a leading letter, +/// so only interior characters can need replacing. +pub(super) fn const_name(type_name: &str, value: &str) -> String { + sanitize_identifier( + &format!("{type_name}{}", to_pascal_case(value)), + IdentifierStart::Letter, + ) +} diff --git a/crates/vespertide-exporter/src/gorm/mod.rs b/crates/vespertide-exporter/src/gorm/mod.rs index 03763fe0..a7d831af 100644 --- a/crates/vespertide-exporter/src/gorm/mod.rs +++ b/crates/vespertide-exporter/src/gorm/mod.rs @@ -1,59 +1,13 @@ -use std::collections::{HashMap, HashSet}; +mod enums; +mod render; +mod types; -use crate::orm::OrmExporter; -use vespertide_config::DEFAULT_GORM_PACKAGE_NAME; -use vespertide_core::schema::column::{ - ColumnType, ComplexColumnType, EnumValues, SimpleColumnKind, SimpleColumnType, -}; -use vespertide_core::schema::constraint::TableConstraint; -use vespertide_core::schema::names::ColumnName; -use vespertide_core::{ColumnDef, DefaultValue, ReferenceAction, ReferenceActionKind, TableDef}; -use vespertide_naming::{IdentifierStart, sanitize_identifier}; - -/// Track which Go imports are actually used to generate minimal import statements. -#[expect( - clippy::struct_excessive_bools, - reason = "four independent import-presence flags; enum would add verbosity without clarity" -)] -#[derive(Default)] -struct UsedImports { - needs_time: bool, - needs_uuid: bool, - needs_datatypes: bool, - needs_decimal: bool, -} +use std::path::Path; -impl UsedImports { - fn add_column_type(&mut self, col_type: &ColumnType) { - match col_type { - ColumnType::Simple(ty) => match ty { - SimpleColumnType::Date - | SimpleColumnType::Time - | SimpleColumnType::Timestamp - | SimpleColumnType::Timestamptz => { - self.needs_time = true; - } - SimpleColumnType::Uuid => { - self.needs_uuid = true; - } - SimpleColumnType::Json => { - self.needs_datatypes = true; - } - _ => {} - }, - ColumnType::Complex(ty) => { - if let ComplexColumnType::Numeric { .. } = ty { - self.needs_decimal = true; - } - if let ComplexColumnType::Custom { custom_type } = ty - && custom_type.to_uppercase() == "JSONB" - { - self.needs_datatypes = true; - } - } - } - } -} +use crate::orm::OrmExporter; +use crate::scope_names::scope_of; +use render::{gofmt_layout, imports_for, package_names, render_header, render_table_body}; +use vespertide_core::TableDef; pub struct GormExporter; @@ -71,924 +25,149 @@ impl OrmExporter for GormExporter { } } -/// GORM exporter that honors `vespertide.json`'s `gorm` config section -/// (currently the effective Go package name — see -/// `VespertideConfig::gorm_package_name`, which resolves an explicit -/// `gorm.package_name` or infers one from the actual export directory — -/// emitted at the top of every file). Mirrors `seaorm::SeaOrmExporterWithConfig`. -pub struct GormExporterWithConfig<'a> { - package_name: &'a str, -} - -impl<'a> GormExporterWithConfig<'a> { - /// `package_name` is the already-resolved effective package name (see - /// `VespertideConfig::gorm_package_name`), not the raw `GormConfig` - /// field — resolving requires the actual export directory, which the - /// `GormConfig` alone doesn't know. - pub fn new(package_name: &'a str) -> Self { - Self { package_name } - } - - pub fn render_entity(&self, table: &TableDef) -> Result { - Ok(render_entity_inner_with_package( - table, - &[], - self.package_name, - )) - } - - pub fn render_entity_with_schema( - &self, - table: &TableDef, - schema: &[TableDef], - ) -> Result { - Ok(render_entity_inner_with_package( - table, - schema, - self.package_name, - )) - } -} - -/// Render a GORM entity for the given table definition. -pub fn render_entity(table: &TableDef) -> Result { - Ok(render_entity_inner(table, &[])) -} - -/// Render a GORM entity with full schema context for reverse-relation (HasMany) generation. -pub fn render_entity_with_schema(table: &TableDef, schema: &[TableDef]) -> Result { - Ok(render_entity_inner(table, schema)) -} - -#[cfg(test)] -pub(crate) fn to_pascal_case_for_tests(s: &str) -> String { - to_pascal_case(s) -} - -fn render_entity_inner(table: &TableDef, schema: &[TableDef]) -> String { - render_entity_inner_with_package(table, schema, DEFAULT_GORM_PACKAGE_NAME) -} - -fn render_entity_inner_with_package( - table: &TableDef, - schema: &[TableDef], - package_name: &str, -) -> String { - let mut lines: Vec = Vec::new(); - - let struct_name = - sanitize_identifier(&to_pascal_case(&table.name), IdentifierStart::Underscore); - - // Find enum names that appear in multiple schema tables (need qualified Go type names) - let conflicting_enums: HashSet = { - let mut counts: HashMap = HashMap::new(); - for col in &table.columns { - if let ColumnType::Complex(ComplexColumnType::Enum { name, .. }) = &col.r#type { - counts - .entry(sanitize_identifier( - &to_pascal_case(name), - IdentifierStart::Underscore, - )) - .or_insert(1); - } - } - for other in schema { - if other.name == table.name { - continue; - } - let mut seen = HashSet::new(); - for col in &other.columns { - if let ColumnType::Complex(ComplexColumnType::Enum { name, .. }) = &col.r#type { - let pascal = - sanitize_identifier(&to_pascal_case(name), IdentifierStart::Underscore); - if seen.insert(pascal.clone()) { - *counts.entry(pascal).or_default() += 1; - } - } - } - } - counts - .into_iter() - .filter(|(_, c)| *c > 1) - .map(|(n, _)| n) - .collect() - }; - - // Collect enums defined in this table's columns, with qualified names where needed - let enums: Vec<(&str, &EnumValues, String)> = table - .columns - .iter() - .filter_map(|col| { - if let ColumnType::Complex(ComplexColumnType::Enum { name, values }) = &col.r#type { - let pascal = - sanitize_identifier(&to_pascal_case(name), IdentifierStart::Underscore); - let qualified = if conflicting_enums.contains(&pascal) { - format!("{struct_name}{pascal}") - } else { - pascal - }; - Some((name.as_str(), values, qualified)) - } else { - None - } - }) - .collect(); - let enum_name_map: HashMap<&str, String> = enums - .iter() - .map(|(name, _, qualified)| (*name, qualified.clone())) +/// Go package name for renders that have no export directory to derive one +/// from, and the fallback when the directory's name does not yield a usable +/// Go identifier. +const DEFAULT_GORM_PACKAGE_NAME: &str = "models"; + +/// Go reserved words, which can't be used as a package name. +const GO_RESERVED_WORDS: &[&str] = &[ + "break", + "default", + "func", + "interface", + "select", + "case", + "defer", + "go", + "map", + "struct", + "chan", + "else", + "goto", + "package", + "switch", + "const", + "fallthrough", + "if", + "range", + "type", + "continue", + "for", + "import", + "return", + "var", +]; + +/// Sanitize a candidate string into a valid, idiomatic Go package identifier: +/// lowercase ASCII letters/digits only, must not start with a digit, must +/// not collide with a Go reserved word. Returns `None` when nothing usable +/// remains (e.g. an all-Unicode or empty candidate). +fn sanitize_go_package_name(candidate: &str) -> Option { + let cleaned: String = candidate + .chars() + .filter(char::is_ascii_alphanumeric) + .map(|c| c.to_ascii_lowercase()) .collect(); - let fk_by_column = collect_fk_info(&table.constraints); - - let pk_columns: HashSet = table - .constraints - .iter() - .filter_map(|c| { - if let TableConstraint::PrimaryKey { columns, .. } = c { - Some(columns.clone()) - } else { - None - } - }) - .flatten() - .map(|c| c.as_str().to_owned()) - .collect(); - - let auto_increment = table.constraints.iter().any(|c| { - matches!( - c, - TableConstraint::PrimaryKey { - auto_increment: true, - .. - } - ) - }); - - let is_composite_pk = pk_columns.len() > 1; - - let single_unique_columns: HashSet = table - .constraints - .iter() - .filter_map(|c| { - if let TableConstraint::Unique { columns, .. } = c { - if columns.len() == 1 { - Some(columns[0].as_str().to_owned()) - } else { - None - } - } else { - None - } - }) - .collect(); - - let index_map = collect_index_info(&table.constraints); - let composite_unique_map = collect_composite_unique_info(&table.constraints); - - let mut used_imports = UsedImports::default(); - for col in &table.columns { - used_imports.add_column_type(&col.r#type); + if cleaned.is_empty() || cleaned.starts_with(|c: char| c.is_ascii_digit()) { + return None; } - - let reverse_relations = find_reverse_relations(&table.name, schema); - - // --- Package declaration --- - lines.push(format!("package {package_name}")); - lines.push(String::new()); - - // --- Imports --- - let has_stdlib = used_imports.needs_time; - let has_external = - used_imports.needs_uuid || used_imports.needs_datatypes || used_imports.needs_decimal; - - if has_stdlib || has_external { - lines.push("import (".into()); - if has_stdlib { - lines.push(" \"time\"".into()); - } - if has_stdlib && has_external { - lines.push(String::new()); - } - if used_imports.needs_datatypes { - lines.push(" \"gorm.io/datatypes\"".into()); - } - if used_imports.needs_uuid { - lines.push(" \"github.com/google/uuid\"".into()); - } - if used_imports.needs_decimal { - lines.push(" \"github.com/shopspring/decimal\"".into()); - } - lines.push(")".into()); - lines.push(String::new()); - } - - // --- Enum type declarations --- - for (_, values, qualified_name) in &enums { - render_enum(&mut lines, qualified_name, values); - lines.push(String::new()); + if GO_RESERVED_WORDS.contains(&cleaned.as_str()) { + return None; } - - // --- Struct definition --- - if let Some(ref desc) = table.description { - lines.push(format!("// {}", desc.replace('\n', " "))); - } - - lines.push(format!("type {struct_name} struct {{")); - - // Every real column's field name is reserved up front so belongs-to - // relation fields (single-column and composite) can detect a collision - // regardless of which column — FK or plain — happens to come first in - // the table definition. - let used_field_names: HashSet = table - .columns - .iter() - .map(|c| to_go_field_name(&c.name)) - .collect(); - let mut used_relation_names = used_field_names.clone(); - - for col in &table.columns { - let is_pk = pk_columns.contains(col.name.as_str()); - let is_unique = single_unique_columns.contains(col.name.as_str()); - let indexes = index_map - .get(col.name.as_str()) - .map_or(&[][..], Vec::as_slice); - let composite_unique_name = composite_unique_map.get(col.name.as_str()); - - if let Some(ref comment) = col.comment { - lines.push(format!(" // {}", comment.replace('\n', " "))); - } - - render_column_field( - &mut lines, - col, - is_pk, - auto_increment && !is_composite_pk, - is_unique, - indexes, - composite_unique_name, - &enum_name_map, - ); - - if let Some(fk) = fk_by_column.get(col.name.as_str()) { - render_fk_relation_field(&mut lines, col, fk, &mut used_relation_names); - } - } - - // Composite (multi-column) FK relation fields. GORM supports composite - // associations via comma-separated `foreignKey`/`references` tags, unlike - // Django which has no native equivalent. - for fk in collect_composite_fk_info(&table.constraints) { - render_composite_fk_relation_field(&mut lines, &fk, &mut used_relation_names); - } - - // Reverse relation fields (HasMany) derived from schema context - for rel in &reverse_relations { - let mut constraint_parts: Vec = Vec::new(); - if let Some(ref action) = rel.on_delete { - constraint_parts.push(format!("OnDelete:{}", reference_action_str(action))); - } - if let Some(ref action) = rel.on_update { - constraint_parts.push(format!("OnUpdate:{}", reference_action_str(action))); - } - let fk_field = to_go_field_name(&rel.fk_column); - let gorm_tag = if constraint_parts.is_empty() { - format!("foreignKey:{fk_field}") - } else { - format!( - "foreignKey:{fk_field};constraint:{}", - constraint_parts.join(",") - ) - }; - lines.push(format!( - " {field_name} []{ref_struct} `gorm:\"{gorm_tag}\" json:\"-\"`", - field_name = rel.field_name, - ref_struct = - sanitize_identifier(&to_pascal_case(&rel.ref_table), IdentifierStart::Underscore), - )); - } - - lines.push("}".into()); - lines.push(String::new()); - - // --- TableName() method --- - if needs_table_name_method(&table.name, &struct_name) { - lines.push(format!( - "func ({struct_name}) TableName() string {{ return \"{name}\" }}", - name = table.name, - )); - lines.push(String::new()); - } - - lines.join("\n") -} - -// --------------------------------------------------------------------------- -// FK info collection -// --------------------------------------------------------------------------- - -struct FkInfo { - ref_table: String, - on_delete: Option, - on_update: Option, + Some(cleaned) } -struct CompositeFkInfo { - local_cols: Vec, - ref_table: String, - ref_cols: Vec, - on_delete: Option, - on_update: Option, +/// Go package name for a GORM export: the export directory's final path +/// segment, sanitized into a Go identifier, or [`DEFAULT_GORM_PACKAGE_NAME`] +/// when that segment yields nothing usable. +fn go_package_name(export_dir: &Path) -> String { + export_dir + .file_name() + .and_then(|s| s.to_str()) + .and_then(sanitize_go_package_name) + .unwrap_or_else(|| DEFAULT_GORM_PACKAGE_NAME.to_string()) } -fn collect_composite_fk_info(constraints: &[TableConstraint]) -> Vec { - constraints - .iter() - .filter_map(|c| { - if let TableConstraint::ForeignKey { - columns, - ref_table, - ref_columns, - on_delete, - on_update, - .. - } = c - && columns.len() > 1 - && columns.len() == ref_columns.len() - { - return Some(CompositeFkInfo { - local_cols: columns.iter().map(|c| c.as_str().to_owned()).collect(), - ref_table: ref_table.as_str().to_owned(), - ref_cols: ref_columns.iter().map(|c| c.as_str().to_owned()).collect(), - on_delete: on_delete.clone(), - on_update: on_update.clone(), - }); - } - None - }) - .collect() -} - -fn collect_fk_info(constraints: &[TableConstraint]) -> HashMap { - constraints - .iter() - .filter_map(|c| { - if let TableConstraint::ForeignKey { - columns, - ref_table, - ref_columns, - on_delete, - on_update, - .. - } = c - { - if columns.len() == 1 && ref_columns.len() == 1 { - Some(( - columns[0].as_str().to_owned(), - FkInfo { - ref_table: ref_table.as_str().to_owned(), - on_delete: on_delete.clone(), - on_update: on_update.clone(), - }, - )) - } else { - None - } - } else { - None - } - }) - .collect() -} - -// --------------------------------------------------------------------------- -// Index info collection -// --------------------------------------------------------------------------- - -struct IndexInfo { - name: Option, +/// GORM exporter whose `package` clause names the directory the file is +/// written to, which is what Go expects of it. +pub struct GormExporterWithConfig { + package_name: String, } -fn collect_index_info(constraints: &[TableConstraint]) -> HashMap> { - let mut map: HashMap> = HashMap::new(); - for c in constraints { - if let TableConstraint::Index { name, columns } = c { - for col in columns { - map.entry(col.as_str().to_owned()) - .or_default() - .push(IndexInfo { - name: name.as_ref().map(|n| n.as_str().to_owned()), - }); - } +impl GormExporterWithConfig { + /// `export_dir` is the directory `models.go` is actually written to — + /// whatever wins after resolving `--export-dir`. + pub fn for_export_dir(export_dir: &Path) -> Self { + Self { + package_name: go_package_name(export_dir), } } - map -} -fn collect_composite_unique_info(constraints: &[TableConstraint]) -> HashMap { - let mut map = HashMap::new(); - for c in constraints { - if let TableConstraint::Unique { name, columns, .. } = c - && columns.len() > 1 - { - let uq_name = name.as_ref().map_or_else( - || { - let parts: Vec<&str> = columns.iter().map(ColumnName::as_str).collect(); - format!("uq_{}", parts.join("_")) - }, - |n| n.as_str().to_owned(), - ); - for col in columns { - map.insert(col.as_str().to_owned(), uq_name.clone()); - } - } + /// [`export`] under the directory's package name. + pub fn export(&self, schema: &[TableDef]) -> Result { + Ok(export_with_package(schema, &self.package_name)) } - map -} - -// --------------------------------------------------------------------------- -// Reverse relation discovery -// --------------------------------------------------------------------------- - -struct ReverseRelation { - field_name: String, - ref_table: String, - fk_column: String, - on_delete: Option, - on_update: Option, } -fn find_reverse_relations(table_name: &str, schema: &[TableDef]) -> Vec { - type RawRelation = ( - String, - String, - String, - Option, - Option, - ); - let mut raw: Vec = Vec::new(); - for other in schema { - // Note: self-referencing tables (other.name == table_name) are NOT - // skipped here — a table's own FK column pointing back at itself - // (e.g. categories.parent_id -> categories.id) must still produce a - // reverse has-many ("Children") relation on the same struct. - for c in &other.constraints { - if let TableConstraint::ForeignKey { - columns, - ref_table, - on_delete, - on_update, - .. - } = c - && ref_table.as_str() == table_name - && columns.len() == 1 - { - let fk_col = columns[0].as_str().to_owned(); - let is_self_ref = other.name.as_str() == table_name; - let base_name = if is_self_ref { - "Children".to_string() - } else { - let pascal = sanitize_identifier( - &to_pascal_case(other.name.as_str()), - IdentifierStart::Underscore, - ); - if pascal.ends_with('s') { - pascal - } else { - format!("{pascal}s") - } - }; - raw.push(( - other.name.as_str().to_owned(), - fk_col, - base_name, - on_delete.clone(), - on_update.clone(), - )); - } - } - } - - let mut name_count: HashMap = HashMap::new(); - for (_, _, base_name, _, _) in &raw { - *name_count.entry(base_name.clone()).or_default() += 1; - } - - raw.into_iter() - .map(|(ref_table, fk_col, base_name, on_delete, on_update)| { - let field_name = if *name_count.get(&base_name).unwrap_or(&0) > 1 { - format!("{}By{}", base_name, to_go_field_name(&fk_col)) - } else { - base_name - }; - ReverseRelation { - field_name, - ref_table, - fk_column: fk_col, - on_delete, - on_update, - } - }) - .collect() +/// Render a GORM entity for the given table definition. +pub fn render_entity(table: &TableDef) -> Result { + Ok(render_entity_inner(table, &[])) } -// --------------------------------------------------------------------------- -// Enum rendering -// --------------------------------------------------------------------------- - -fn render_enum(lines: &mut Vec, name: &str, values: &EnumValues) { - // `name` is already the sanitized, PascalCased (and possibly struct-qualified) - // identifier built by the caller — re-running `to_pascal_case` here would - // split on the `_` a leading-digit escape (e.g. `_1users`) introduces and - // silently drop it. - let type_name = name; - - let mut rendered = match values { - EnumValues::String(_) => { - vec![ - format!("type {type_name} string"), - String::new(), - "const (".into(), - ] - } - EnumValues::Integer(_) => { - vec![ - format!("type {type_name} int"), - String::new(), - "const (".into(), - ] - } - }; - - match values { - EnumValues::String(vals) => { - for val in vals { - let const_name = format!("{type_name}{}", to_pascal_case(val)); - rendered.push(format!(" {const_name} {type_name} = \"{val}\"")); - } - } - EnumValues::Integer(vals) => { - for val in vals { - let const_name = format!("{type_name}{}", to_pascal_case(&val.name)); - rendered.push(format!(" {const_name} {type_name} = {}", val.value)); - } - } - } - - rendered.push(")".into()); - lines.extend(rendered); +/// Render a GORM entity with full schema context, which the reverse side of +/// every relation (has-one / has-many) needs. +pub fn render_entity_with_schema(table: &TableDef, schema: &[TableDef]) -> Result { + Ok(render_entity_inner(table, schema)) } -// --------------------------------------------------------------------------- -// Field rendering -// --------------------------------------------------------------------------- - -#[expect( - clippy::too_many_arguments, - reason = "all params are independent field-rendering inputs; a context struct would add noise without reducing coupling" -)] -fn render_column_field( - lines: &mut Vec, - col: &ColumnDef, - is_pk: bool, - auto_increment: bool, - is_unique: bool, - indexes: &[IndexInfo], - composite_unique_name: Option<&String>, - enum_name_map: &HashMap<&str, String>, -) { - let go_type = go_type_for_column_mapped(&col.r#type, col.nullable, enum_name_map); - let field_name = to_go_field_name(&col.name); - let gorm_tag = build_gorm_tag( - col, - is_pk, - auto_increment, - is_unique, - indexes, - composite_unique_name, +fn render_entity_inner(table: &TableDef, schema: &[TableDef]) -> String { + let mut lines = render_header( + DEFAULT_GORM_PACKAGE_NAME, + &imports_for(std::slice::from_ref(table)), ); - - lines.push(format!( - " {field_name} {go_type} `gorm:\"{gorm_tag}\" json:\"{json_name}\"`", - json_name = col.name, - )); + let names = package_names(scope_of(table, schema)); + lines.extend(render_table_body(table, schema, &names)); + gofmt_layout(&lines) } -fn render_fk_relation_field( - lines: &mut Vec, - col: &ColumnDef, - fk: &FkInfo, - used_relation_names: &mut HashSet, -) { - let ref_struct = - sanitize_identifier(&to_pascal_case(&fk.ref_table), IdentifierStart::Underscore); - let fk_field_name = to_go_field_name(&col.name); - let mut relation_field_name = infer_relation_field_name(&col.name); - if relation_field_name == fk_field_name { - relation_field_name = format!("{relation_field_name}{ref_struct}"); - } - // The name above only rules out colliding with this FK's own scalar - // field; it can still collide with an unrelated real column (or another - // relation) elsewhere in the table, so fall back to a numbered suffix. - if used_relation_names.contains(&relation_field_name) { - let mut n = 2; - loop { - let candidate = format!("{relation_field_name}{n}"); - if !used_relation_names.contains(&candidate) { - relation_field_name = candidate; - break; - } - n += 1; - } - } - used_relation_names.insert(relation_field_name.clone()); - - let mut constraint_parts: Vec = Vec::new(); - if let Some(ref action) = fk.on_delete { - constraint_parts.push(format!("OnDelete:{}", reference_action_str(action))); - } - if let Some(ref action) = fk.on_update { - constraint_parts.push(format!("OnUpdate:{}", reference_action_str(action))); - } - - let gorm_tag = if constraint_parts.is_empty() { - format!("foreignKey:{fk_field_name}") - } else { - format!( - "foreignKey:{fk_field_name};constraint:{}", - constraint_parts.join(",") - ) - }; - - let type_expr = if col.nullable { - format!("*{ref_struct}") - } else { - ref_struct - }; - - lines.push(format!( - " {relation_field_name} {type_expr} `gorm:\"{gorm_tag}\" json:\"-\"`" - )); +/// Render a whole schema as one Go source file: a single `package` clause, +/// one import block covering every table, then each table's declarations. +/// Concatenating per-table files instead would repeat the `package` clause, +/// which Go rejects. +pub fn export(schema: &[TableDef]) -> Result { + Ok(export_with_package(schema, DEFAULT_GORM_PACKAGE_NAME)) } -/// Render a belongs-to relation field for a composite (multi-column) FK, -/// using GORM's comma-separated `foreignKey`/`references` tag syntax. -fn render_composite_fk_relation_field( - lines: &mut Vec, - fk: &CompositeFkInfo, - used_relation_names: &mut HashSet, -) { - let ref_struct = - sanitize_identifier(&to_pascal_case(&fk.ref_table), IdentifierStart::Underscore); - - let mut relation_field_name = ref_struct.clone(); - if used_relation_names.contains(&relation_field_name) { - let mut n = 2; - loop { - let candidate = format!("{relation_field_name}{n}"); - if !used_relation_names.contains(&candidate) { - relation_field_name = candidate; - break; - } - n += 1; - } - } - used_relation_names.insert(relation_field_name.clone()); - - let fk_fields: Vec = fk.local_cols.iter().map(|c| to_go_field_name(c)).collect(); - let ref_fields: Vec = fk.ref_cols.iter().map(|c| to_go_field_name(c)).collect(); - - let mut constraint_parts: Vec = Vec::new(); - if let Some(ref action) = fk.on_delete { - constraint_parts.push(format!("OnDelete:{}", reference_action_str(action))); - } - if let Some(ref action) = fk.on_update { - constraint_parts.push(format!("OnUpdate:{}", reference_action_str(action))); - } - - let gorm_tag = if constraint_parts.is_empty() { - format!( - "foreignKey:{};references:{}", - fk_fields.join(","), - ref_fields.join(",") - ) - } else { - format!( - "foreignKey:{};references:{};constraint:{}", - fk_fields.join(","), - ref_fields.join(","), - constraint_parts.join(",") - ) - }; - - lines.push(format!( - " {relation_field_name} {ref_struct} `gorm:\"{gorm_tag}\" json:\"-\"`" - )); -} - -// --------------------------------------------------------------------------- -// GORM tag building -// --------------------------------------------------------------------------- - -fn build_gorm_tag( - col: &ColumnDef, - is_pk: bool, - auto_increment: bool, - is_unique: bool, - indexes: &[IndexInfo], - composite_unique_name: Option<&String>, -) -> String { - let mut parts: Vec = vec![format!("column:{}", col.name)]; - - if is_pk { - parts.push("primaryKey".into()); - } - if is_pk && auto_increment { - parts.push("autoIncrement".into()); - } - if !col.nullable && !is_pk { - parts.push("not null".into()); - } - if is_unique && !is_pk { - parts.push("unique".into()); - } - - match &col.r#type { - ColumnType::Simple(SimpleColumnType::Text) => parts.push("type:text".into()), - ColumnType::Simple(SimpleColumnType::Xml) => parts.push("type:xml".into()), - ColumnType::Simple(SimpleColumnType::Interval) => parts.push("type:interval".into()), - ColumnType::Simple(SimpleColumnType::Date) => parts.push("type:date".into()), - ColumnType::Simple(SimpleColumnType::Time) => parts.push("type:time".into()), - ColumnType::Simple(SimpleColumnType::Uuid) => parts.push("type:uuid".into()), - ColumnType::Complex(ComplexColumnType::Varchar { length }) => { - parts.push(format!("size:{length}")); - } - ColumnType::Complex(ComplexColumnType::Char { length }) => { - parts.push(format!("size:{length}")); - parts.push("type:char".into()); - } - ColumnType::Complex(ComplexColumnType::Numeric { precision, scale }) => { - parts.push(format!("type:numeric({precision},{scale})")); - } - ColumnType::Complex(ComplexColumnType::Custom { custom_type }) => { - parts.push(format!("type:{custom_type}")); - } - _ => {} - } - - if let Some(ref default) = col.default - && let Some(tag) = build_default_tag(default) - { - parts.push(tag); - } - - for idx in indexes { - if let Some(ref name) = idx.name { - parts.push(format!("index:{name}")); - } else { - parts.push("index".into()); - } - } - - if let Some(uq_name) = composite_unique_name { - parts.push(format!("uniqueIndex:{uq_name}")); - } - - parts.join(";") -} - -fn build_default_tag(default: &DefaultValue) -> Option { - let sql = default.to_sql(); - if sql.contains('(') { - return None; // Skip server-side function calls like NOW() - } - Some(format!("default:{sql}")) -} - -// --------------------------------------------------------------------------- -// Type mapping -// --------------------------------------------------------------------------- - -pub(super) fn go_type_for_column_mapped( - col_type: &ColumnType, - nullable: bool, - enum_map: &HashMap<&str, String>, -) -> String { - let base = match col_type { - ColumnType::Complex(ComplexColumnType::Enum { name, .. }) => { - enum_map.get(name.as_str()).cloned().unwrap_or_else(|| { - sanitize_identifier(&to_pascal_case(name), IdentifierStart::Underscore) - }) +fn export_with_package(schema: &[TableDef], package_name: &str) -> String { + let mut lines = render_header(package_name, &imports_for(schema)); + let names = package_names(schema); + for (i, table) in schema.iter().enumerate() { + if i > 0 { + lines.push(String::new()); } - _ => go_base_type(col_type), - }; - if nullable { format!("*{base}") } else { base } -} - -fn go_base_type(col_type: &ColumnType) -> String { - match col_type { - ColumnType::Simple(ty) => match SimpleColumnKind::from(*ty) { - SimpleColumnKind::SmallInt => "int16".to_string(), - SimpleColumnKind::Integer => "int32".to_string(), - SimpleColumnKind::BigInt => "int64".to_string(), - SimpleColumnKind::Real => "float32".to_string(), - SimpleColumnKind::DoublePrecision => "float64".to_string(), - SimpleColumnKind::Text - | SimpleColumnKind::Xml - | SimpleColumnKind::Inet - | SimpleColumnKind::Cidr - | SimpleColumnKind::Macaddr - | SimpleColumnKind::Interval => "string".to_string(), - SimpleColumnKind::Boolean => "bool".to_string(), - SimpleColumnKind::Date - | SimpleColumnKind::Time - | SimpleColumnKind::Timestamp - | SimpleColumnKind::Timestamptz => "time.Time".to_string(), - SimpleColumnKind::Bytea => "[]byte".to_string(), - SimpleColumnKind::Uuid => "uuid.UUID".to_string(), - SimpleColumnKind::Json => "datatypes.JSON".to_string(), - }, - ColumnType::Complex(ty) => match ty { - ComplexColumnType::Varchar { .. } | ComplexColumnType::Char { .. } => { - "string".to_string() - } - ComplexColumnType::Custom { custom_type } => { - if custom_type.to_uppercase() == "JSONB" { - "datatypes.JSON".to_string() - } else { - "string".to_string() - } - } - ComplexColumnType::Numeric { .. } => "decimal.Decimal".to_string(), - // `#[non_exhaustive]` future-variant guard; unreachable today. - #[cfg(not(tarpaulin_include))] - _ => { - unreachable!("ComplexColumnType is #[non_exhaustive]; all variants matched") - } - }, - } -} - -fn reference_action_str(action: &ReferenceAction) -> &'static str { - match ReferenceActionKind::from(action) { - ReferenceActionKind::Cascade => "CASCADE", - ReferenceActionKind::Restrict => "RESTRICT", - ReferenceActionKind::SetNull => "SET NULL", - ReferenceActionKind::SetDefault => "SET DEFAULT", - ReferenceActionKind::NoAction => "NO ACTION", + lines.extend(render_table_body(table, schema, &names)); } + gofmt_layout(&lines) } -// --------------------------------------------------------------------------- -// 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() -} +#[cfg(test)] +mod tests { + use std::path::Path; -pub(super) fn to_go_field_name(s: &str) -> String { - let pascal = to_pascal_case(s); - // Apply Go conventions for common abbreviations - let pascal = pascal.replace("Id", "ID"); - // Go identifiers can't start with a digit or contain non-alphanumeric - // characters; a leading `_` is legal (matches Rust module / Java field - // escaping elsewhere in the exporter). - sanitize_identifier(&pascal, IdentifierStart::Underscore) -} + use rstest::rstest; -pub(super) fn infer_relation_field_name(fk_column: &str) -> String { - let base = fk_column.strip_suffix("_id").unwrap_or(fk_column); - sanitize_identifier(&to_pascal_case(base), IdentifierStart::Underscore) -} + use super::go_package_name; -fn pascal_to_snake(s: &str) -> String { - let mut result = String::new(); - for c in s.chars() { - if c.is_uppercase() && !result.is_empty() { - result.push('_'); - } - result.extend(c.to_lowercase()); + #[rstest] + #[case::default_dir_matches_folder("src/models", "models")] + #[case::infers_from_folder_name("src/entities", "entities")] + #[case::strips_invalid_chars("src/db-models", "dbmodels")] + #[case::falls_back_when_digit_led("src/2024-models", "models")] + #[case::falls_back_on_non_ascii("src/모델", "models")] + #[case::falls_back_on_reserved_word("src/type", "models")] + fn go_package_name_inferred_from_export_dir(#[case] export_dir: &str, #[case] expected: &str) { + assert_eq!(go_package_name(Path::new(export_dir)), expected); } - result } - -pub(super) fn needs_table_name_method(table_name: &str, struct_name: &str) -> bool { - let snake = pascal_to_snake(struct_name); - let gorm_default = format!("{snake}s"); - gorm_default != table_name -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests; diff --git a/crates/vespertide-exporter/src/gorm/render.rs b/crates/vespertide-exporter/src/gorm/render.rs new file mode 100644 index 00000000..0b951f90 --- /dev/null +++ b/crates/vespertide-exporter/src/gorm/render.rs @@ -0,0 +1,840 @@ +use std::collections::{HashMap, HashSet}; + +use super::enums::{const_name, render_enum}; +use super::types::{UsedImports, go_type_for_column_mapped, is_go_string}; +use crate::constraint_scan::{ + BackRelation, FkDetails, collect_back_relations, primary_key_columns, single_column_fk_details, + single_column_uniques, +}; +use crate::enum_scan::{collect_table_enums, variant_names}; +use crate::scope_names::ScopeNames; +use crate::utils::common::{ + CompositeFk, claim_binding, collect_composite_fks, integer_enum_variant_value, string_literal, + unquote, +}; +use vespertide_core::schema::column::{ + ColumnType, ComplexColumnType, EnumValues, SimpleColumnType, +}; +use vespertide_core::schema::constraint::TableConstraint; +use vespertide_core::{ColumnDef, DefaultValue, ReferenceAction, TableDef}; +use vespertide_naming::{ + IdentifierStart, build_index_name, build_unique_constraint_name, pluralize, sanitize_identifier, +}; + +/// The Go imports the columns of `tables` need. +pub(super) fn imports_for(tables: &[TableDef]) -> UsedImports { + let mut used = UsedImports::default(); + for col in tables.iter().flat_map(|table| &table.columns) { + used.add_column_type(&col.r#type); + } + used +} + +/// The `package` clause and the import block, stdlib first. +pub(super) fn render_header(package_name: &str, used_imports: &UsedImports) -> Vec { + let mut lines = vec![format!("package {package_name}"), String::new()]; + + let has_stdlib = used_imports.needs_time; + let has_external = + used_imports.needs_uuid || used_imports.needs_datatypes || used_imports.needs_decimal; + + if has_stdlib || has_external { + lines.push("import (".into()); + if has_stdlib { + lines.push(" \"time\"".into()); + } + if has_stdlib && has_external { + lines.push(String::new()); + } + if used_imports.needs_uuid { + lines.push(" \"github.com/google/uuid\"".into()); + } + if used_imports.needs_decimal { + lines.push(" \"github.com/shopspring/decimal\"".into()); + } + if used_imports.needs_datatypes { + lines.push(" \"gorm.io/datatypes\"".into()); + } + lines.push(")".into()); + lines.push(String::new()); + } + lines +} + +/// Lay rendered `lines` out the way `gofmt` does, so the file passes a +/// project's `gofmt -l` check untouched: tab indents, the name / type / rest +/// cells of consecutive struct fields or constants padded into columns, and +/// no doubled blank lines. A comment or an import path has no cells and ends +/// the run of lines being aligned, as it does in `gofmt`. +pub(super) fn gofmt_layout(lines: &[String]) -> String { + fn flush(run: &mut Vec<[&str; 3]>, out: &mut Vec) { + let name_width = run.iter().map(|cells| cells[0].len()).max().unwrap_or(0); + let type_width = run.iter().map(|cells| cells[1].len()).max().unwrap_or(0); + for [name, ty, rest] in run.drain(..) { + out.push(format!("\t{name: = Vec::new(); + let mut run: Vec<[&str; 3]> = Vec::new(); + for line in lines { + let cells = line + .strip_prefix(" ") + .filter(|body| !body.starts_with("//")) + .and_then(|body| { + let (name, rest) = body.split_once(' ')?; + let (ty, rest) = rest.split_once(' ')?; + Some([name, ty, rest]) + }); + if let Some(cells) = cells { + run.push(cells); + continue; + } + flush(&mut run, &mut out); + if let Some(body) = line.strip_prefix(" ") { + out.push(format!("\t{body}")); + } else if !(line.is_empty() && out.last().is_some_and(String::is_empty)) { + out.push(line.clone()); + } + } + out.join("\n") +} + +/// Every name `tables` declare in their package: structs, enum types, then +/// enum constants, which Go also puts at package scope (`Status` + `code` is +/// `StatusCode`, and so is the struct of a `status_code` table). +pub(super) fn package_names(tables: &[TableDef]) -> ScopeNames { + let mut names = ScopeNames::collect(tables, exported_go_name, exported_go_name); + for table in tables { + for (enum_name, values) in collect_table_enums(table) { + let type_name = names.enum_type(&table.name, enum_name).to_string(); + for (index, variant) in variant_names(values).into_iter().enumerate() { + names.claim_member( + &table.name, + enum_name, + index, + const_name(&type_name, variant), + ); + } + } + } + names +} + +/// The struct a table is declared as; a table outside the package's schema — +/// a foreign key may point there — keeps its natural name. +fn struct_name_of(names: &ScopeNames, table: &str) -> String { + names + .table(table) + .map_or_else(|| exported_go_name(table), str::to_string) +} + +/// Everything below the header for one table: enum types, the struct, and +/// its methods. +pub(super) fn render_table_body( + table: &TableDef, + schema: &[TableDef], + names: &ScopeNames, +) -> Vec { + let mut lines: Vec = Vec::new(); + + let struct_name = struct_name_of(names, &table.name); + + let enums = collect_table_enums(table); + let enum_name_map: HashMap<&str, String> = enums + .iter() + .map(|(name, _)| (*name, names.enum_type(&table.name, name).to_string())) + .collect(); + + let fk_by_column = single_column_fk_details(&table.constraints); + let pk_columns = primary_key_columns(&table.constraints); + + let auto_increment = table.constraints.iter().any(|c| { + matches!( + c, + TableConstraint::PrimaryKey { + auto_increment: true, + .. + } + ) + }); + + let is_composite_pk = pk_columns.len() > 1; + + let single_unique_columns = single_column_uniques(&table.constraints); + + let index_map = collect_index_names(table); + let composite_unique_map = collect_composite_unique_names(table); + + // --- Enum type declarations --- + for (enum_name, values) in &enums { + let const_names: Vec = (0..variant_names(values).len()) + .map(|index| names.member(&table.name, enum_name, index).to_string()) + .collect(); + render_enum(&mut lines, &enum_name_map[enum_name], &const_names, values); + lines.push(String::new()); + } + + // --- Struct definition --- + if let Some(ref desc) = table.description { + lines.push(format!("// {}", desc.replace('\n', " "))); + } + + lines.push(format!("type {struct_name} struct {{")); + + // One set of taken names for the whole struct, columns first: no relation + // field — belongs-to, composite or has-many — may take a column's name, + // whichever order the table declares them in. + let (field_names, mut taken) = column_field_names(table); + + for col in &table.columns { + let is_pk = pk_columns.contains(col.name.as_str()); + let is_unique = single_unique_columns.contains(col.name.as_str()); + let indexes = index_map + .get(col.name.as_str()) + .map_or(&[][..], Vec::as_slice); + let composite_unique_name = composite_unique_map.get(col.name.as_str()); + + if let Some(ref comment) = col.comment { + lines.push(format!(" // {}", comment.replace('\n', " "))); + } + + render_column_field( + &mut lines, + col, + &field_names[col.name.as_str()], + is_pk, + auto_increment && !is_composite_pk, + is_unique, + indexes, + composite_unique_name, + &enum_name_map, + ); + + if let Some(fk) = fk_by_column.get(col.name.as_str()) { + render_fk_relation_field( + &mut lines, + col, + &field_names[col.name.as_str()], + fk, + schema, + names, + &mut taken, + ); + } + } + + // Composite (multi-column) FK relation fields, through GORM's + // comma-separated `foreignKey`/`references` tags. + for fk in collect_composite_fks(table) { + render_composite_fk_relation_field( + &mut lines, + &fk, + &field_names, + schema, + names, + &mut taken, + ); + } + + // Reverse relation fields (has-one / has-many) derived from schema context + let back_relations = collect_back_relations(&table.name, schema); + let reverse_names = reverse_field_names(&table.name, &back_relations); + for (rel, field_name) in back_relations.iter().zip(reverse_names) { + let foreign_key: Vec = rel + .fk_columns + .iter() + .map(|c| field_name_in(schema, &rel.source_table, c)) + .collect(); + let ref_columns: Vec<&str> = rel.ref_columns.iter().map(String::as_str).collect(); + let gorm_tag = relation_tag( + &foreign_key, + &reference_fields(schema, &table.name, &ref_columns), + rel.on_delete.as_ref(), + rel.on_update.as_ref(), + ); + let source_struct = struct_name_of(names, &rel.source_table); + let go_type = if rel.is_one_to_one { + format!("*{source_struct}") + } else { + format!("[]{source_struct}") + }; + lines.push(format!( + " {field_name} {go_type} {tag}", + field_name = claim_binding(field_name, &mut taken), + tag = struct_tag(&gorm_tag, "-"), + )); + } + + lines.push("}".into()); + lines.push(String::new()); + + // GORM would otherwise derive the table name by pluralizing the struct + // name, which does not reproduce an arbitrary database name. + lines.push(format!( + "func ({struct_name}) TableName() string {{ return {name} }}", + name = string_literal(&table.name), + )); + lines.push(String::new()); + + lines +} + +// --------------------------------------------------------------------------- +// Index / unique names +// --------------------------------------------------------------------------- + +/// Index names per column, spelled as the SQL layer spells them so +/// `AutoMigrate` finds the index the migration created instead of adding a +/// second one. Every column of a composite index carries the same name, which +/// is how GORM groups them. +fn collect_index_names(table: &TableDef) -> HashMap<&str, Vec> { + let mut map: HashMap<&str, Vec> = HashMap::new(); + for c in &table.constraints { + if let TableConstraint::Index { name, columns } = c { + let index_name = build_index_name(&table.name, columns, name.as_deref()); + for col in columns { + map.entry(col.as_str()) + .or_default() + .push(index_name.clone()); + } + } + } + map +} + +/// Composite unique-index name per column, spelled as the SQL layer spells it. +fn collect_composite_unique_names(table: &TableDef) -> HashMap<&str, String> { + let mut map = HashMap::new(); + for c in &table.constraints { + if let TableConstraint::Unique { name, columns, .. } = c + && columns.len() > 1 + { + let uq_name = build_unique_constraint_name(&table.name, columns, name.as_deref()); + for col in columns { + map.insert(col.as_str(), uq_name.clone()); + } + } + } + map +} + +// --------------------------------------------------------------------------- +// Reverse relation naming +// --------------------------------------------------------------------------- + +/// Go field names for `rels`, in order: `Children` for a self-reference, +/// otherwise the source struct — as is for a has-one, pluralized for a +/// has-many. A name more than one relation would take is told apart by the +/// key it hangs on (`SettingsByCreatedByUserID`). +fn reverse_field_names(target: &str, rels: &[BackRelation]) -> Vec { + let bases: Vec = rels + .iter() + .map(|rel| { + if rel.source_table == target { + "Children".to_string() + } else if rel.is_one_to_one { + exported_go_name(&rel.source_table) + } else { + exported_go_name(&pluralize(&rel.source_table)) + } + }) + .collect(); + + rels.iter() + .zip(&bases) + .map(|(rel, base)| { + if bases.iter().filter(|other| *other == base).count() > 1 { + let key: String = rel.fk_columns.iter().map(|c| to_go_field_name(c)).collect(); + format!("{base}By{key}") + } else { + base.clone() + } + }) + .collect() +} + +// --------------------------------------------------------------------------- +// Field rendering +// --------------------------------------------------------------------------- + +#[expect( + clippy::too_many_arguments, + reason = "independent field-rendering inputs, read once at a single call site" +)] +fn render_column_field( + lines: &mut Vec, + col: &ColumnDef, + field_name: &str, + is_pk: bool, + auto_increment: bool, + is_unique: bool, + indexes: &[String], + composite_unique_name: Option<&String>, + enum_name_map: &HashMap<&str, String>, +) { + let go_type = go_type_for_column_mapped(&col.r#type, col.nullable, enum_name_map); + let gorm_tag = build_gorm_tag( + col, + is_pk, + auto_increment, + is_unique, + indexes, + composite_unique_name, + ); + + lines.push(format!( + " {field_name} {go_type} {tag}", + tag = struct_tag(&gorm_tag, &col.name), + )); +} + +fn render_fk_relation_field( + lines: &mut Vec, + col: &ColumnDef, + fk_field_name: &str, + fk: &FkDetails, + schema: &[TableDef], + names: &ScopeNames, + taken: &mut HashSet, +) { + let ref_struct = struct_name_of(names, fk.ref_table); + let mut relation_field_name = go_relation_field_name(&col.name); + if relation_field_name == fk_field_name { + relation_field_name = format!("{relation_field_name}{ref_struct}"); + } + // The name above only rules out colliding with this FK's own scalar + // field; it can still collide with an unrelated real column (or another + // relation) elsewhere in the table. + let relation_field_name = claim_binding(relation_field_name, taken); + + let gorm_tag = relation_tag( + &[fk_field_name.to_string()], + &reference_fields(schema, fk.ref_table, &[fk.ref_column]), + fk.on_delete, + fk.on_update, + ); + + // Always a pointer, nullable or not: a struct that held its target by + // value could not hold itself (`parent_id NOT NULL`), nor a target that + // holds it back, and Go rejects both as an invalid recursive type. + lines.push(format!( + " {relation_field_name} *{ref_struct} {tag}", + tag = struct_tag(&gorm_tag, "-"), + )); +} + +/// Render a belongs-to relation field for a composite (multi-column) FK, +/// using GORM's comma-separated `foreignKey`/`references` tag syntax. +fn render_composite_fk_relation_field( + lines: &mut Vec, + fk: &CompositeFk, + field_names: &HashMap<&str, String>, + schema: &[TableDef], + names: &ScopeNames, + taken: &mut HashSet, +) { + let ref_struct = struct_name_of(names, fk.ref_table); + + let relation_field_name = claim_binding(ref_struct.clone(), taken); + + let fk_fields: Vec = fk + .local_cols + .iter() + .map(|c| { + field_names + .get(*c) + .map_or_else(|| to_go_field_name(c), Clone::clone) + }) + .collect(); + let gorm_tag = relation_tag( + &fk_fields, + &reference_fields(schema, fk.ref_table, &fk.ref_cols), + fk.on_delete, + fk.on_update, + ); + + lines.push(format!( + " {relation_field_name} *{ref_struct} {tag}", + tag = struct_tag(&gorm_tag, "-"), + )); +} + +// --------------------------------------------------------------------------- +// GORM tag building +// --------------------------------------------------------------------------- + +/// A field's struct tag. `reflect.StructTag` reads each value as a quoted Go +/// string, so a `\` or `"` from a column name or default is escaped there; the +/// tag as a whole is a raw string unless a value holds a backtick, the one +/// character a raw string cannot. +fn struct_tag(gorm: &str, json: &str) -> String { + let tag = format!( + "gorm:{} json:{}", + string_literal(gorm), + string_literal(json) + ); + if tag.contains('`') { + string_literal(&tag) + } else { + format!("`{tag}`") + } +} + +/// The `references` fields of a relation on `ref_table`: none when the key is +/// the target's primary key, which GORM assumes. A composite key is always +/// spelled out, since GORM pairs its fields by position. +fn reference_fields(schema: &[TableDef], ref_table: &str, ref_columns: &[&str]) -> Vec { + if let [column] = ref_columns { + let is_primary_key = schema + .iter() + .find(|t| t.name.as_str() == ref_table) + .is_none_or(|target| { + let pk = primary_key_columns(&target.constraints); + pk.len() == 1 && pk.contains(column) + }); + if is_primary_key { + return Vec::new(); + } + } + ref_columns + .iter() + .map(|c| field_name_in(schema, ref_table, c)) + .collect() +} + +/// The `gorm:"..."` tag of a relation field: the fields on the foreign-key +/// side, the fields they reference when GORM could not infer them, and the +/// referential actions. +fn relation_tag( + foreign_key: &[String], + references: &[String], + on_delete: Option<&ReferenceAction>, + on_update: Option<&ReferenceAction>, +) -> String { + let mut parts = vec![format!("foreignKey:{}", foreign_key.join(","))]; + if !references.is_empty() { + parts.push(format!("references:{}", references.join(","))); + } + let actions: Vec = [("OnDelete", on_delete), ("OnUpdate", on_update)] + .into_iter() + .filter_map(|(key, action)| Some(format!("{key}:{}", action?.to_sql_keyword()))) + .collect(); + if !actions.is_empty() { + parts.push(format!("constraint:{}", actions.join(","))); + } + parts.join(";") +} + +fn build_gorm_tag( + col: &ColumnDef, + is_pk: bool, + auto_increment: bool, + is_unique: bool, + indexes: &[String], + composite_unique_name: Option<&String>, +) -> String { + let mut parts: Vec = vec![format!("column:{}", col.name)]; + + if is_pk { + parts.push("primaryKey".into()); + } + if is_pk && auto_increment { + parts.push("autoIncrement".into()); + } + if !col.nullable && !is_pk { + parts.push("not null".into()); + } + if is_unique && !is_pk { + parts.push("unique".into()); + } + + match &col.r#type { + ColumnType::Simple(SimpleColumnType::Text) => parts.push("type:text".into()), + ColumnType::Simple(SimpleColumnType::Xml) => parts.push("type:xml".into()), + ColumnType::Simple(SimpleColumnType::Interval) => parts.push("type:interval".into()), + ColumnType::Simple(SimpleColumnType::Date) => parts.push("type:date".into()), + ColumnType::Simple(SimpleColumnType::Time) => parts.push("type:time".into()), + ColumnType::Simple(SimpleColumnType::Uuid) => parts.push("type:uuid".into()), + ColumnType::Simple(SimpleColumnType::Inet) => parts.push("type:inet".into()), + ColumnType::Simple(SimpleColumnType::Cidr) => parts.push("type:cidr".into()), + ColumnType::Simple(SimpleColumnType::Macaddr) => parts.push("type:macaddr".into()), + ColumnType::Complex(ComplexColumnType::Varchar { length }) => { + parts.push(format!("size:{length}")); + } + // GORM only applies `size` to its built-in string type; a bare `type:char` + // is `char(1)` on every database. + ColumnType::Complex(ComplexColumnType::Char { length }) => { + parts.push(format!("type:char({length})")); + } + ColumnType::Complex(ComplexColumnType::Numeric { precision, scale }) => { + parts.push(format!("type:numeric({precision},{scale})")); + } + ColumnType::Complex(ComplexColumnType::Custom { custom_type }) => { + parts.push(format!("type:{custom_type}")); + } + _ => {} + } + + if let Some(ref default) = col.default + && let Some(tag) = build_default_tag(default, &col.r#type) + { + parts.push(tag); + } + + for name in indexes { + parts.push(format!("index:{name}")); + } + + if let Some(uq_name) = composite_unique_name { + parts.push(format!("uniqueIndex:{uq_name}")); + } + + parts.join(";") +} + +fn build_default_tag(default: &DefaultValue, col_type: &ColumnType) -> Option { + let sql = default.to_sql(); + // A function call has no literal to pin, and `;` would end the gorm + // setting early, taking every later setting with it. + if sql.contains(['(', ';']) { + return None; + } + // An integer enum's default may name a variant; the column stores its value. + if let ColumnType::Complex(ComplexColumnType::Enum { + values: EnumValues::Integer(variants), + .. + }) = col_type + && let Some(value) = integer_enum_variant_value(variants, unquote(&sql)) + { + return Some(format!("default:{value}")); + } + // GORM takes a string field's default for the value itself, so the doubled + // SQL escape must not reach it. It also trims every quote off both ends + // rather than one pair, so a value that starts or ends with a quote has no + // spelling. Every other kind of field keeps the tag as the SQL it was + // written in. + if is_go_string(col_type) && sql.len() >= 2 && sql.starts_with('\'') && sql.ends_with('\'') { + let value = unquote(&sql).replace("''", "'"); + if value.starts_with(['\'', '"']) || value.ends_with(['\'', '"']) { + return None; + } + return Some(format!("default:'{value}'")); + } + Some(format!("default:{sql}")) +} + +// --------------------------------------------------------------------------- +// Naming utilities +// --------------------------------------------------------------------------- + +pub(super) use crate::python_naming::to_pascal_case; + +/// Exported Go identifier for a database name: PascalCase, with a digit-led +/// start given an upper-case letter prefix. GORM skips unexported struct +/// fields, and a `_`-led type is unreachable from other packages. +pub(super) fn exported_go_name(s: &str) -> String { + exported(&to_pascal_case(s)) +} + +/// Go field name for a column: [`exported_go_name`] with Go's `ID` initialism. +fn to_go_field_name(s: &str) -> String { + exported(&go_initialisms(&to_pascal_case(s))) +} + +/// Go field name for every column of `table`, claimed in declaration order so +/// two columns that map to one Go name (`user_id`, `userId`) get distinct +/// fields, with the set those claims filled — the struct's relation fields +/// claim against the same one. +fn column_field_names(table: &TableDef) -> (HashMap<&str, String>, HashSet) { + // Every struct gets a `TableName` method, and Go rejects a field of the + // same name. + let mut taken = HashSet::from(["TableName".to_string()]); + let names = table + .columns + .iter() + .map(|col| { + ( + col.name.as_str(), + claim_binding(to_go_field_name(&col.name), &mut taken), + ) + }) + .collect(); + (names, taken) +} + +/// The field name `column` has in `table_name`'s struct: its claimed name when +/// that table is part of `schema`, the plain derivation otherwise (a +/// single-table render knows nothing about its FK targets). +fn field_name_in(schema: &[TableDef], table_name: &str, column: &str) -> String { + schema + .iter() + .find(|t| t.name.as_str() == table_name) + .and_then(|t| column_field_names(t).0.remove(column)) + .unwrap_or_else(|| to_go_field_name(column)) +} + +/// Go field name for a belongs-to relation: the FK column without its `_id` +/// suffix, in PascalCase. +fn go_relation_field_name(fk_column: &str) -> String { + exported_go_name(vespertide_naming::infer_relation_field_name(fk_column)) +} + +fn exported(pascal: &str) -> String { + let mut name = sanitize_identifier(pascal, IdentifierStart::Letter); + // `Letter` copies the case of the first letter it finds (`x1users`), and Go + // exports by case. The first byte is always an ASCII letter after + // sanitizing, so the slice cannot split a character. + name[..1].make_ascii_uppercase(); + name +} + +/// Every `Id` that ends a PascalCase word becomes `ID`, as Go spells the +/// initialism; `Identity` and `Idx` keep their words. +fn go_initialisms(pascal: &str) -> String { + let chars: Vec = pascal.chars().collect(); + let mut out = String::with_capacity(pascal.len()); + let mut i = 0; + while i < chars.len() { + let ends_word = chars.get(i + 2).is_none_or(|c| !c.is_ascii_lowercase()); + if chars[i] == 'I' && chars.get(i + 1) == Some(&'d') && ends_word { + out.push_str("ID"); + i += 2; + } else { + out.push(chars[i]); + i += 1; + } + } + out +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + use vespertide_core::schema::column::{ + ColumnType, ComplexColumnType, EnumValues, SimpleColumnType, + }; + use vespertide_core::{ColumnDef, TableDef}; + + use super::{ + build_default_tag, column_field_names, go_relation_field_name, package_names, struct_tag, + to_go_field_name, + }; + + #[rstest] + #[case("user_id", "UserID")] + #[case("id", "ID")] + #[case("created_at", "CreatedAt")] + #[case("profile_image", "ProfileImage")] + #[case("media_id", "MediaID")] + #[case("identity", "Identity")] + #[case("idx", "Idx")] + #[case("1st_place", "X1stPlace")] + fn column_names_become_exported_go_fields(#[case] input: &str, #[case] expected: &str) { + assert_eq!(to_go_field_name(input), expected); + } + + /// Two columns that map to one Go name get distinct fields, in declaration + /// order, and none takes the name of the struct's own `TableName` method. + #[test] + fn column_field_names_disambiguate_go_collisions() { + let integer = || ColumnType::Simple(SimpleColumnType::Integer); + let table = TableDef { + name: "sessions".into(), + description: None, + columns: vec![ + ColumnDef::new("user_id", integer(), false), + ColumnDef::new("userId", integer(), false), + ColumnDef::new("table_name", integer(), false), + ], + constraints: vec![], + }; + let (names, _) = column_field_names(&table); + assert_eq!(names["user_id"], "UserID"); + assert_eq!(names["userId"], "UserID2"); + assert_eq!(names["table_name"], "TableName2"); + } + + /// Constants sit at package scope next to the types: values that fold onto + /// one name are numbered, and an empty value does not spell its own type. + #[test] + fn enum_constants_are_claimed_in_the_package_scope() { + let state = ColumnType::Complex(ComplexColumnType::Enum { + name: "state".into(), + values: EnumValues::String(vec![ + "in progress".into(), + "in-progress".into(), + String::new(), + ]), + }); + let table = TableDef { + name: "ticket".into(), + description: None, + columns: vec![ColumnDef::new("state", state, false)], + constraints: vec![], + }; + let names = package_names(std::slice::from_ref(&table)); + assert_eq!(names.member("ticket", "state", 0), "StateIn_progress"); + assert_eq!(names.member("ticket", "state", 1), "StateIn_progress2"); + assert_eq!(names.member("ticket", "state", 2), "State2"); + } + + #[rstest] + #[case::plain( + "column:id;primaryKey", + "id", + r#"`gorm:"column:id;primaryKey" json:"id"`"# + )] + #[case::quote_and_backslash( + r#"column:a"b\c"#, + r#"a"b\c"#, + r#"`gorm:"column:a\"b\\c" json:"a\"b\\c"`"# + )] + #[case::backtick("column:a`b", "a`b", r#""gorm:\"column:a`b\" json:\"a`b\"""#)] + fn struct_tags_escape_what_their_literal_cannot_hold( + #[case] gorm: &str, + #[case] json: &str, + #[case] expected: &str, + ) { + assert_eq!(struct_tag(gorm, json), expected); + } + + /// GORM reads a string field's default as the value and every other + /// field's as SQL, so only the former loses the doubled quote — and, as + /// GORM trims every quote off its ends, a value that ends in one. + #[rstest] + #[case::string(SimpleColumnType::Text, "'draft'", Some("default:'draft'"))] + #[case::doubled_quote_in_a_string(SimpleColumnType::Text, "'it''s'", Some("default:'it's'"))] + #[case::quotes_inside_a_string( + SimpleColumnType::Text, + r#"'a "b" c'"#, + Some(r#"default:'a "b" c'"#) + )] + #[case::string_ending_in_a_quote(SimpleColumnType::Text, r#"'say "hi"'"#, None)] + #[case::string_starting_with_a_quote(SimpleColumnType::Text, "'''tis'", None)] + #[case::doubled_quote_in_json( + SimpleColumnType::Json, + r#"'{"a": "it''s"}'"#, + Some(r#"default:'{"a": "it''s"}'"#) + )] + #[case::number(SimpleColumnType::Integer, "0", Some("default:0"))] + #[case::function_call(SimpleColumnType::Timestamp, "now()", None)] + #[case::setting_separator(SimpleColumnType::Text, "'a;b'", None)] + fn defaults_become_gorm_default_tags( + #[case] ty: SimpleColumnType, + #[case] sql: &str, + #[case] expected: Option<&str>, + ) { + let tag = build_default_tag(&sql.into(), &ColumnType::Simple(ty)); + assert_eq!(tag.as_deref(), expected); + } + + #[rstest] + #[case("user_id", "User")] + #[case("author_id", "Author")] + #[case("parent_id", "Parent")] + #[case("node", "Node")] + fn fk_columns_name_their_relation_field(#[case] input: &str, #[case] expected: &str) { + assert_eq!(go_relation_field_name(input), expected); + } +} diff --git a/crates/vespertide-exporter/src/gorm/tests/mod.rs b/crates/vespertide-exporter/src/gorm/tests/mod.rs deleted file mode 100644 index 5bf88e72..00000000 --- a/crates/vespertide-exporter/src/gorm/tests/mod.rs +++ /dev/null @@ -1,1137 +0,0 @@ -use std::collections::HashMap; - -use insta::assert_snapshot; -use rstest::rstest; -use vespertide_core::schema::column::{ - ColumnType, ComplexColumnType, EnumValues, SimpleColumnType, -}; -use vespertide_core::schema::constraint::TableConstraint; -use vespertide_core::{ColumnDef, DefaultValue, NumValue, ReferenceAction, TableDef}; - -use super::{ - GormExporterWithConfig, go_type_for_column_mapped, infer_relation_field_name, - needs_table_name_method, render_entity, render_entity_with_schema, to_go_field_name, -}; - -mod relations; - -fn col(name: &str, ty: ColumnType) -> ColumnDef { - ColumnDef { - name: name.into(), - r#type: ty, - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - } -} - -// ----------------------------------------------------------------------- -// Type mapping unit tests -// ----------------------------------------------------------------------- - -#[rstest] -#[case(ColumnType::Simple(SimpleColumnType::SmallInt), false, "int16")] -#[case(ColumnType::Simple(SimpleColumnType::Integer), false, "int32")] -#[case(ColumnType::Simple(SimpleColumnType::BigInt), false, "int64")] -#[case(ColumnType::Simple(SimpleColumnType::Real), false, "float32")] -#[case( - ColumnType::Simple(SimpleColumnType::DoublePrecision), - false, - "float64" -)] -#[case(ColumnType::Simple(SimpleColumnType::Text), false, "string")] -#[case(ColumnType::Simple(SimpleColumnType::Boolean), false, "bool")] -#[case(ColumnType::Simple(SimpleColumnType::Timestamp), false, "time.Time")] -#[case(ColumnType::Simple(SimpleColumnType::Timestamptz), false, "time.Time")] -#[case(ColumnType::Simple(SimpleColumnType::Date), false, "time.Time")] -#[case(ColumnType::Simple(SimpleColumnType::Time), false, "time.Time")] -#[case(ColumnType::Simple(SimpleColumnType::Uuid), false, "uuid.UUID")] -#[case(ColumnType::Simple(SimpleColumnType::Json), false, "datatypes.JSON")] -#[case(ColumnType::Simple(SimpleColumnType::Bytea), false, "[]byte")] -#[case(ColumnType::Simple(SimpleColumnType::Inet), false, "string")] -#[case(ColumnType::Complex(ComplexColumnType::Varchar { length: 255 }), false, "string")] -#[case(ColumnType::Complex(ComplexColumnType::Numeric { precision: 10, scale: 2 }), false, "decimal.Decimal")] -#[case(ColumnType::Complex(ComplexColumnType::Custom { custom_type: "JSONB".into() }), false, "datatypes.JSON")] -#[case(ColumnType::Complex(ComplexColumnType::Custom { custom_type: "jsonb".into() }), false, "datatypes.JSON")] -#[case(ColumnType::Complex(ComplexColumnType::Custom { custom_type: "TEXT".into() }), false, "string")] -#[case(ColumnType::Simple(SimpleColumnType::Integer), true, "*int32")] -#[case(ColumnType::Simple(SimpleColumnType::Text), true, "*string")] -#[case(ColumnType::Simple(SimpleColumnType::Timestamp), true, "*time.Time")] -fn test_go_type_mapping( - #[case] col_type: ColumnType, - #[case] nullable: bool, - #[case] expected: &str, -) { - assert_eq!( - go_type_for_column_mapped(&col_type, nullable, &HashMap::new()), - expected - ); -} - -#[rstest] -#[case("user_id", "UserID")] -#[case("id", "ID")] -#[case("created_at", "CreatedAt")] -#[case("profile_image", "ProfileImage")] -#[case("media_id", "MediaID")] -fn test_to_go_field_name(#[case] input: &str, #[case] expected: &str) { - assert_eq!(to_go_field_name(input), expected); -} - -#[rstest] -#[case("user_id", "User")] -#[case("author_id", "Author")] -#[case("parent_id", "Parent")] -#[case("node", "Node")] -fn test_infer_relation_field_name(#[case] input: &str, #[case] expected: &str) { - assert_eq!(infer_relation_field_name(input), expected); -} - -#[rstest] -#[case("User", "user", true)] -#[case("User", "users", false)] -#[case("OrderItem", "order_items", false)] -#[case("OrderItem", "order_item", true)] -fn test_needs_table_name_method( - #[case] struct_name: &str, - #[case] table_name: &str, - #[case] expected: bool, -) { - assert_eq!(needs_table_name_method(table_name, struct_name), expected); -} - -// ----------------------------------------------------------------------- -// Snapshot tests (a) simple table - columns only -// ----------------------------------------------------------------------- - -#[test] -fn test_basic_table() { - let table = TableDef { - name: "users".into(), - description: Some("User accounts".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::Complex(ComplexColumnType::Varchar { length: 255 }), - nullable: false, - default: None, - comment: None, - 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, - }, - 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, - }, - ], - constraints: vec![ - TableConstraint::PrimaryKey { - auto_increment: true, - columns: vec!["id".into()], - strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), - }, - TableConstraint::Unique { - name: None, - columns: vec!["email".into()], - strategy: vespertide_core::UniqueConstraintStrategy::DeleteDuplicates { - keep: vespertide_core::KeepPolicy::First, - }, - }, - ], - }; - let result = render_entity(&table).unwrap(); - assert_snapshot!(result); -} - -// ----------------------------------------------------------------------- -// Snapshot tests (b) FK -// ----------------------------------------------------------------------- - -#[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()], - strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), - }, - TableConstraint::ForeignKey { - name: None, - columns: vec!["author_id".into()], - ref_table: "users".into(), - ref_columns: vec!["id".into()], - on_delete: Some(ReferenceAction::Cascade), - on_update: None, - orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), - }, - TableConstraint::Index { - name: Some("ix_posts__author_id".into()), - columns: vec!["author_id".into()], - }, - ], - }; - let result = render_entity(&table).unwrap(); - assert_snapshot!(result); -} - -// ----------------------------------------------------------------------- -// Snapshot tests (c) enums -// ----------------------------------------------------------------------- - -#[test] -fn test_table_with_string_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: Some(DefaultValue::String("'pending'".into())), - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }, - ], - constraints: vec![TableConstraint::PrimaryKey { - auto_increment: true, - columns: vec!["id".into()], - strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), - }], - }; - 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()], - strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), - }], - }; - let result = render_entity(&table).unwrap(); - assert_snapshot!(result); -} - -// ----------------------------------------------------------------------- -// Snapshot tests (d) composite PK + nullable -// ----------------------------------------------------------------------- - -#[test] -fn test_composite_pk_nullable() { - 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)), - ColumnDef { - name: "quantity".into(), - r#type: ColumnType::Simple(SimpleColumnType::Integer), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }, - ColumnDef { - name: "note".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: false, - columns: vec!["order_id".into(), "product_id".into()], - strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), - }, - TableConstraint::ForeignKey { - name: None, - columns: vec!["order_id".into()], - ref_table: "orders".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), - }, - TableConstraint::ForeignKey { - name: None, - columns: vec!["product_id".into()], - ref_table: "products".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), - }, - TableConstraint::Unique { - name: Some("uq_order_items__order_product".into()), - columns: vec!["order_id".into(), "product_id".into()], - strategy: vespertide_core::UniqueConstraintStrategy::DeleteDuplicates { - keep: vespertide_core::KeepPolicy::First, - }, - }, - ], - }; - let result = render_entity(&table).unwrap(); - assert_snapshot!(result); -} - -// ----------------------------------------------------------------------- -// All simple types -// ----------------------------------------------------------------------- - -#[test] -fn test_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()], - strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), - }], - }; - let result = render_entity(&table).unwrap(); - assert_snapshot!(result); -} - -// ----------------------------------------------------------------------- -// Snapshot tests (e) JSONB custom type -// ----------------------------------------------------------------------- - -#[test] -fn test_table_with_jsonb_column() { - let table = TableDef { - name: "documents".into(), - description: None, - columns: vec![ - col("id", ColumnType::Simple(SimpleColumnType::Integer)), - col( - "data", - ColumnType::Complex(ComplexColumnType::Custom { - custom_type: "JSONB".into(), - }), - ), - col("meta", ColumnType::Simple(SimpleColumnType::Json)), - ], - constraints: vec![TableConstraint::PrimaryKey { - auto_increment: true, - columns: vec!["id".into()], - strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), - }], - }; - let result = render_entity(&table).unwrap(); - assert_snapshot!(result); -} - -// ----------------------------------------------------------------------- -// Snapshot tests (f) server-side default skipped -// ----------------------------------------------------------------------- - -#[test] -fn test_server_default_skipped() { - let table = TableDef { - name: "events".into(), - description: None, - columns: vec![ - col("id", ColumnType::Simple(SimpleColumnType::Integer)), - 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: "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, - }, - ], - constraints: vec![TableConstraint::PrimaryKey { - auto_increment: true, - columns: vec!["id".into()], - strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), - }], - }; - let result = render_entity(&table).unwrap(); - // Server-side function calls must not appear as GORM default tags - assert!(!result.contains("default:NOW()")); - // Literal integer defaults are still included - assert!(result.contains("default:0")); - assert_snapshot!(result); -} - -// ----------------------------------------------------------------------- -// Conflicting enum names across tables → qualified Go type name -// ----------------------------------------------------------------------- - -#[test] -fn test_conflicting_enum_names_qualified() { - let orders = TableDef { - name: "orders".into(), - description: None, - columns: vec![ - col("id", ColumnType::Simple(SimpleColumnType::Integer)), - col( - "status", - ColumnType::Complex(ComplexColumnType::Enum { - name: "status".into(), - values: EnumValues::String(vec!["pending".into(), "done".into()]), - }), - ), - ], - constraints: vec![TableConstraint::PrimaryKey { - auto_increment: true, - columns: vec!["id".into()], - strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), - }], - }; - let tasks = TableDef { - name: "tasks".into(), - description: None, - columns: vec![ - col("id", ColumnType::Simple(SimpleColumnType::Integer)), - col( - "status", - ColumnType::Complex(ComplexColumnType::Enum { - name: "status".into(), - values: EnumValues::String(vec!["open".into(), "closed".into()]), - }), - ), - ], - constraints: vec![TableConstraint::PrimaryKey { - auto_increment: true, - columns: vec!["id".into()], - strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), - }], - }; - let schema = vec![orders.clone(), tasks]; - let result = render_entity_with_schema(&orders, &schema).unwrap(); - assert!( - result.contains("OrdersStatus"), - "Expected qualified enum name 'OrdersStatus' in:\n{result}" - ); -} - -// ----------------------------------------------------------------------- -// Char column → type:char + size in GORM tag -// ----------------------------------------------------------------------- - -#[test] -fn test_char_type_column() { - let table = TableDef { - name: "codes".into(), - description: None, - columns: vec![ - col("id", ColumnType::Simple(SimpleColumnType::Integer)), - col( - "code", - ColumnType::Complex(ComplexColumnType::Char { length: 3 }), - ), - ], - constraints: vec![TableConstraint::PrimaryKey { - auto_increment: true, - columns: vec!["id".into()], - strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), - }], - }; - let result = render_entity(&table).unwrap(); - assert!( - result.contains("type:char"), - "Expected type:char in GORM tag" - ); - assert!(result.contains("size:3"), "Expected size:3 in GORM tag"); -} - -// ----------------------------------------------------------------------- -// FK field name collision: infer == go_field → disambiguate with ref struct -// ----------------------------------------------------------------------- - -#[test] -fn test_fk_relation_field_name_collision() { - // Column "user" (no _id suffix): infer→"User", go_field→"User" → same → "UserUsers" - let table = TableDef { - name: "posts".into(), - description: None, - columns: vec![ - col("id", ColumnType::Simple(SimpleColumnType::Integer)), - col("user", ColumnType::Simple(SimpleColumnType::Integer)), - ], - constraints: vec![ - TableConstraint::PrimaryKey { - auto_increment: true, - columns: vec!["id".into()], - strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), - }, - TableConstraint::ForeignKey { - name: None, - columns: vec!["user".into()], - ref_table: "users".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), - }, - ], - }; - let result = render_entity(&table).unwrap(); - assert!( - result.contains("UserUsers"), - "Expected disambiguated relation name 'UserUsers' in:\n{result}" - ); -} - -// ----------------------------------------------------------------------- -// Reverse relation disambiguation: two FKs to same target → ByField suffix -// ----------------------------------------------------------------------- - -#[test] -fn test_reverse_relation_disambiguation() { - let users = TableDef { - name: "users".into(), - description: None, - columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], - constraints: vec![TableConstraint::PrimaryKey { - auto_increment: true, - columns: vec!["id".into()], - strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), - }], - }; - let events = TableDef { - name: "events".into(), - description: None, - columns: vec![ - col("id", ColumnType::Simple(SimpleColumnType::Integer)), - col("creator_id", ColumnType::Simple(SimpleColumnType::Integer)), - col("attendee_id", ColumnType::Simple(SimpleColumnType::Integer)), - ], - constraints: vec![ - TableConstraint::PrimaryKey { - auto_increment: true, - columns: vec!["id".into()], - strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), - }, - TableConstraint::ForeignKey { - name: None, - columns: vec!["creator_id".into()], - ref_table: "users".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), - }, - TableConstraint::ForeignKey { - name: None, - columns: vec!["attendee_id".into()], - ref_table: "users".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), - }, - ], - }; - let schema = vec![users.clone(), events]; - let result = render_entity_with_schema(&users, &schema).unwrap(); - assert!( - result.contains("EventsByCreatorID"), - "Expected 'EventsByCreatorID' in:\n{result}" - ); - assert!( - result.contains("EventsByAttendeeID"), - "Expected 'EventsByAttendeeID' in:\n{result}" - ); -} - -// ----------------------------------------------------------------------- -// Snapshot tests (g) HasMany with on_delete/on_update constraint -// ----------------------------------------------------------------------- - -#[test] -fn test_has_many_with_constraint() { - let users = TableDef { - name: "users".into(), - description: None, - columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], - constraints: vec![TableConstraint::PrimaryKey { - auto_increment: true, - columns: vec!["id".into()], - strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), - }], - }; - let posts = TableDef { - name: "posts".into(), - description: None, - columns: vec![ - col("id", ColumnType::Simple(SimpleColumnType::Integer)), - col("user_id", ColumnType::Simple(SimpleColumnType::Integer)), - ], - constraints: vec![ - TableConstraint::PrimaryKey { - auto_increment: true, - columns: vec!["id".into()], - strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), - }, - TableConstraint::ForeignKey { - name: None, - columns: vec!["user_id".into()], - ref_table: "users".into(), - ref_columns: vec!["id".into()], - on_delete: Some(ReferenceAction::Cascade), - on_update: Some(ReferenceAction::Restrict), - orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), - }, - ], - }; - let schema = vec![users.clone(), posts.clone()]; - let result = render_entity_with_schema(&users, &schema).unwrap(); - assert!(result.contains("OnDelete:CASCADE")); - assert!(result.contains("OnUpdate:RESTRICT")); - assert_snapshot!(result); -} - -// ----------------------------------------------------------------------- -// Numeric column: add_column_type needs_decimal, build_gorm_tag Numeric, decimal import -// ----------------------------------------------------------------------- - -#[test] -fn test_numeric_column_gorm() { - let table = TableDef { - name: "prices".into(), - description: None, - columns: vec![ - col("id", ColumnType::Simple(SimpleColumnType::Integer)), - col( - "amount", - ColumnType::Complex(ComplexColumnType::Numeric { - precision: 10, - scale: 2, - }), - ), - ], - constraints: vec![TableConstraint::PrimaryKey { - auto_increment: true, - columns: vec!["id".into()], - strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), - }], - }; - let result = render_entity(&table).unwrap(); - assert!( - result.contains("type:numeric(10,2)"), - "expected numeric GORM tag" - ); - assert!( - result.contains("decimal.Decimal"), - "expected decimal.Decimal type" - ); - assert!( - result.contains("github.com/shopspring/decimal"), - "expected decimal import" - ); -} - -// ----------------------------------------------------------------------- -// Unnamed Index: build_gorm_tag unnamed index tag + collect_index_info inner body -// ----------------------------------------------------------------------- - -#[test] -fn test_unnamed_index_gorm() { - let table = TableDef { - name: "searches".into(), - description: None, - columns: vec![ - col("id", ColumnType::Simple(SimpleColumnType::Integer)), - col("query", ColumnType::Simple(SimpleColumnType::Text)), - ], - constraints: vec![ - TableConstraint::PrimaryKey { - auto_increment: true, - columns: vec!["id".into()], - strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), - }, - TableConstraint::Index { - name: None, - columns: vec!["query".into()], - }, - ], - }; - let result = render_entity(&table).unwrap(); - assert!(result.contains(";index\""), "expected unnamed index tag"); -} - -// ----------------------------------------------------------------------- -// Named Index: build_gorm_tag named index tag + collect_index_info name closure -// ----------------------------------------------------------------------- - -#[test] -fn test_named_index_gorm() { - let table = TableDef { - name: "searches".into(), - description: None, - columns: vec![ - col("id", ColumnType::Simple(SimpleColumnType::Integer)), - col("query", ColumnType::Simple(SimpleColumnType::Text)), - ], - constraints: vec![ - TableConstraint::PrimaryKey { - auto_increment: true, - columns: vec!["id".into()], - strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), - }, - TableConstraint::Index { - name: Some("ix_searches__query".into()), - columns: vec!["query".into()], - }, - ], - }; - let result = render_entity(&table).unwrap(); - assert!( - result.contains("index:ix_searches__query"), - "expected named index tag" - ); -} - -// ----------------------------------------------------------------------- -// Unnamed composite unique: collect_composite_unique_info auto-name (uq_{cols}) -// ----------------------------------------------------------------------- - -#[test] -fn test_unnamed_composite_unique_gorm() { - let table = TableDef { - name: "order_lines".into(), - description: None, - columns: vec![ - col("id", ColumnType::Simple(SimpleColumnType::Integer)), - col("order_id", ColumnType::Simple(SimpleColumnType::Integer)), - col( - "sku", - ColumnType::Complex(ComplexColumnType::Varchar { length: 50 }), - ), - ], - constraints: vec![ - TableConstraint::PrimaryKey { - auto_increment: true, - columns: vec!["id".into()], - strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), - }, - TableConstraint::Unique { - name: None, - columns: vec!["order_id".into(), "sku".into()], - strategy: vespertide_core::UniqueConstraintStrategy::DeleteDuplicates { - keep: vespertide_core::KeepPolicy::First, - }, - }, - ], - }; - let result = render_entity(&table).unwrap(); - assert!( - result.contains("uniqueIndex:uq_order_id_sku"), - "expected auto-generated uniqueIndex name" - ); -} - -// ----------------------------------------------------------------------- -// Singular source table name: find_reverse_relations appends 's' for non-plural -// ----------------------------------------------------------------------- - -#[test] -fn test_singular_source_table_name() { - let user = TableDef { - name: "user".into(), - description: None, - columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], - constraints: vec![TableConstraint::PrimaryKey { - auto_increment: true, - columns: vec!["id".into()], - strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), - }], - }; - let comment = TableDef { - name: "comment".into(), - description: None, - columns: vec![ - col("id", ColumnType::Simple(SimpleColumnType::Integer)), - col("user_id", ColumnType::Simple(SimpleColumnType::Integer)), - ], - constraints: vec![ - TableConstraint::PrimaryKey { - auto_increment: true, - columns: vec!["id".into()], - strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), - }, - TableConstraint::ForeignKey { - name: None, - columns: vec!["user_id".into()], - ref_table: "user".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), - }, - ], - }; - let schema = vec![user.clone(), comment]; - let result = render_entity_with_schema(&user, &schema).unwrap(); - // "comment" → pascal "Comment" → doesn't end with 's' → appended 's' → "Comments" - assert!( - result.contains("Comments"), - "expected 'Comments' plural for singular 'comment' table" - ); -} - -// ----------------------------------------------------------------------- -// FK with on_update + nullable column: render_fk_relation_field lines 547, 559-560 -// ----------------------------------------------------------------------- - -#[test] -fn test_fk_with_on_update_and_nullable() { - let posts = 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: 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()], - strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), - }, - TableConstraint::ForeignKey { - name: None, - columns: vec!["author_id".into()], - ref_table: "users".into(), - ref_columns: vec!["id".into()], - on_delete: Some(ReferenceAction::Cascade), - on_update: Some(ReferenceAction::Restrict), - orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), - }, - ], - }; - let result = render_entity(&posts).unwrap(); - assert!( - result.contains("OnUpdate:RESTRICT"), - "expected OnUpdate constraint" - ); - assert!( - result.contains("*Users"), - "expected nullable FK pointer type" - ); -} - -// ----------------------------------------------------------------------- -// reference_action_str: SetNull, SetDefault, NoAction (via FK on_delete) -// ----------------------------------------------------------------------- - -#[rstest] -#[case(ReferenceAction::SetNull, "SET NULL")] -#[case(ReferenceAction::SetDefault, "SET DEFAULT")] -#[case(ReferenceAction::NoAction, "NO ACTION")] -fn test_gorm_fk_on_delete_actions(#[case] action: ReferenceAction, #[case] expected: &str) { - let table = TableDef { - name: "posts".into(), - description: None, - columns: vec![ - col("id", ColumnType::Simple(SimpleColumnType::Integer)), - col("author_id", ColumnType::Simple(SimpleColumnType::Integer)), - ], - constraints: vec![ - TableConstraint::PrimaryKey { - auto_increment: true, - columns: vec!["id".into()], - strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), - }, - TableConstraint::ForeignKey { - name: None, - columns: vec!["author_id".into()], - ref_table: "users".into(), - ref_columns: vec!["id".into()], - on_delete: Some(action), - on_update: None, - orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), - }, - ], - }; - let result = render_entity(&table).unwrap(); - assert!( - result.contains(&format!("OnDelete:{expected}")), - "expected OnDelete:{expected} in:\n{result}" - ); -} - -// ----------------------------------------------------------------------- -// to_pascal_case: None arm via double-underscore table name -// ----------------------------------------------------------------------- - -#[test] -fn test_gorm_double_underscore_table_name() { - // "order__item" splits into ["order", "", "item"] → empty word hits None => String::new() - let table = TableDef { - name: "order__item".into(), - description: None, - columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], - constraints: vec![TableConstraint::PrimaryKey { - auto_increment: true, - columns: vec!["id".into()], - strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), - }], - }; - let result = render_entity(&table).unwrap(); - assert!( - result.contains("type OrderItem struct"), - "expected pascal-cased struct with double underscore" - ); -} - -// ----------------------------------------------------------------------- -// collect_composite_unique_info: named branch (|n| n.as_str().to_owned()) -// ----------------------------------------------------------------------- - -#[test] -fn test_named_composite_unique_gorm() { - let table = TableDef { - name: "tenants".into(), - description: None, - columns: vec![ - col("id", ColumnType::Simple(SimpleColumnType::Integer)), - col("tenant_id", ColumnType::Simple(SimpleColumnType::Integer)), - col("name", ColumnType::Simple(SimpleColumnType::Text)), - ], - constraints: vec![ - TableConstraint::PrimaryKey { - auto_increment: true, - columns: vec!["id".into()], - strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), - }, - TableConstraint::Unique { - name: Some("uq_tenant_name".into()), - columns: vec!["tenant_id".into(), "name".into()], - strategy: vespertide_core::UniqueConstraintStrategy::DeleteDuplicates { - keep: vespertide_core::KeepPolicy::First, - }, - }, - ], - }; - let result = render_entity(&table).unwrap(); - assert!( - result.contains("uniqueIndex:uq_tenant_name"), - "expected named uniqueIndex tag in GORM output" - ); -} - -// ----------------------------------------------------------------------- -// GormExporterWithConfig: package_name reaches the `package` declaration -// ----------------------------------------------------------------------- - -fn simple_table() -> TableDef { - TableDef { - name: "users".into(), - description: None, - columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], - constraints: vec![TableConstraint::PrimaryKey { - auto_increment: true, - columns: vec!["id".into()], - strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), - }], - } -} - -#[test] -fn test_default_package_name_is_models() { - let table = simple_table(); - let exporter = GormExporterWithConfig::new("models"); - let result = exporter.render_entity(&table).unwrap(); - assert!( - result.starts_with("package models\n"), - "expected default 'package models', got:\n{result}" - ); -} - -#[test] -fn test_custom_package_name_from_config() { - let table = simple_table(); - let exporter = GormExporterWithConfig::new("entities"); - let result = exporter.render_entity(&table).unwrap(); - assert!( - result.starts_with("package entities\n"), - "expected 'package entities', got:\n{result}" - ); -} - -#[test] -fn test_custom_package_name_with_schema_context() { - let table = simple_table(); - let schema = vec![table.clone()]; - let exporter = GormExporterWithConfig::new("entities"); - let result = exporter.render_entity_with_schema(&table, &schema).unwrap(); - assert!( - result.starts_with("package entities\n"), - "expected 'package entities', got:\n{result}" - ); -} diff --git a/crates/vespertide-exporter/src/gorm/tests/relations.rs b/crates/vespertide-exporter/src/gorm/tests/relations.rs deleted file mode 100644 index 61b2405c..00000000 --- a/crates/vespertide-exporter/src/gorm/tests/relations.rs +++ /dev/null @@ -1,143 +0,0 @@ -use super::*; - -// ----------------------------------------------------------------------- -// Composite (multi-column) FK relation field -// ----------------------------------------------------------------------- - -fn composite_fk_table() -> TableDef { - TableDef { - name: "order_items".into(), - description: None, - columns: vec![ - col("order_id", ColumnType::Simple(SimpleColumnType::Integer)), - col("region_id", ColumnType::Simple(SimpleColumnType::Integer)), - ], - constraints: vec![ - TableConstraint::PrimaryKey { - auto_increment: false, - columns: vec!["order_id".into(), "region_id".into()], - strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), - }, - TableConstraint::ForeignKey { - name: None, - columns: vec!["order_id".into(), "region_id".into()], - ref_table: "order_regions".into(), - ref_columns: vec!["order_id".into(), "region_id".into()], - on_delete: Some(ReferenceAction::Cascade), - on_update: Some(ReferenceAction::Restrict), - orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), - }, - ], - } -} - -#[test] -fn test_composite_fk_relation_field() { - let result = render_entity(&composite_fk_table()).unwrap(); - assert!( - result.contains( - "OrderRegions OrderRegions `gorm:\"foreignKey:OrderID,RegionID;references:OrderID,RegionID;constraint:OnDelete:CASCADE,OnUpdate:RESTRICT\" json:\"-\"`" - ), - "expected composite FK relation field in GORM output, got:\n{result}" - ); -} - -#[test] -fn test_composite_fk_relation_field_name_collision_suffixed() { - // A column already named "OrderRegions" (Go field name) collides with the - // natural composite-FK relation field name, forcing a numeric suffix. - let mut table = composite_fk_table(); - table.columns.push(col( - "order_regions", - ColumnType::Simple(SimpleColumnType::Text), - )); - let result = render_entity(&table).unwrap(); - assert!( - result.contains("OrderRegions2 OrderRegions `gorm:\"foreignKey:OrderID,RegionID"), - "expected suffixed relation field name on collision, got:\n{result}" - ); -} - -#[test] -fn test_composite_fk_relation_field_name_double_collision_increments_suffix() { - // Both "OrderRegions" and "OrderRegions2" are already taken by columns, - // so the collision loop must advance past its first candidate too. - let mut table = composite_fk_table(); - table.columns.push(col( - "order_regions", - ColumnType::Simple(SimpleColumnType::Text), - )); - table.columns.push(col( - "order_regions2", - ColumnType::Simple(SimpleColumnType::Text), - )); - let result = render_entity(&table).unwrap(); - assert!( - result.contains("OrderRegions3 OrderRegions `gorm:\"foreignKey:OrderID,RegionID"), - "expected double-suffixed relation field name on double collision, got:\n{result}" - ); -} - -// ----------------------------------------------------------------------- -// Self-referencing FK (single table referencing itself, e.g. a tree/ -// hierarchy structure: categories.parent_id -> categories.id) -// ----------------------------------------------------------------------- - -fn self_referencing_table() -> TableDef { - TableDef { - name: "categories".into(), - description: None, - columns: vec![ - col("id", ColumnType::Simple(SimpleColumnType::Integer)), - ColumnDef { - name: "parent_id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Integer), - 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()], - strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), - }, - TableConstraint::ForeignKey { - name: None, - columns: vec!["parent_id".into()], - ref_table: "categories".into(), - ref_columns: vec!["id".into()], - on_delete: Some(ReferenceAction::SetNull), - on_update: None, - orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), - }, - ], - } -} - -#[test] -fn test_self_referencing_fk_forward_relation() { - let table = self_referencing_table(); - let schema = vec![table.clone()]; - let result = render_entity_with_schema(&table, &schema).unwrap(); - assert!( - result.contains("Parent *Categories `gorm:\"foreignKey:ParentID"), - "expected forward self-ref relation field, got:\n{result}" - ); -} - -#[test] -fn test_self_referencing_fk_reverse_relation() { - let table = self_referencing_table(); - let schema = vec![table.clone()]; - let result = render_entity_with_schema(&table, &schema).unwrap(); - assert!( - result.contains("Children []Categories `gorm:\"foreignKey:ParentID"), - "expected reverse (has-many) self-ref relation field, got:\n{result}" - ); -} diff --git a/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__all_simple_types.snap b/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__all_simple_types.snap deleted file mode 100644 index c7c36956..00000000 --- a/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__all_simple_types.snap +++ /dev/null @@ -1,36 +0,0 @@ ---- -source: crates/vespertide-exporter/src/gorm/mod.rs -expression: result ---- -package models - -import ( - "time" - - "gorm.io/datatypes" - "github.com/google/uuid" -) - -type TypeTest struct { - ColSmallint int16 `gorm:"column:col_smallint;not null" json:"col_smallint"` - ColInteger int32 `gorm:"column:col_integer;primaryKey" json:"col_integer"` - ColBigint int64 `gorm:"column:col_bigint;not null" json:"col_bigint"` - ColReal float32 `gorm:"column:col_real;not null" json:"col_real"` - ColDouble float64 `gorm:"column:col_double;not null" json:"col_double"` - ColText string `gorm:"column:col_text;not null;type:text" json:"col_text"` - ColBoolean bool `gorm:"column:col_boolean;not null" json:"col_boolean"` - ColDate time.Time `gorm:"column:col_date;not null;type:date" json:"col_date"` - ColTime time.Time `gorm:"column:col_time;not null;type:time" json:"col_time"` - ColTimestamp time.Time `gorm:"column:col_timestamp;not null" json:"col_timestamp"` - ColTimestamptz time.Time `gorm:"column:col_timestamptz;not null" json:"col_timestamptz"` - ColInterval string `gorm:"column:col_interval;not null;type:interval" json:"col_interval"` - ColBytea []byte `gorm:"column:col_bytea;not null" json:"col_bytea"` - ColUuid uuid.UUID `gorm:"column:col_uuid;not null;type:uuid" json:"col_uuid"` - ColJson datatypes.JSON `gorm:"column:col_json;not null" json:"col_json"` - ColInet string `gorm:"column:col_inet;not null" json:"col_inet"` - ColCidr string `gorm:"column:col_cidr;not null" json:"col_cidr"` - ColMacaddr string `gorm:"column:col_macaddr;not null" json:"col_macaddr"` - ColXml string `gorm:"column:col_xml;not null;type:xml" json:"col_xml"` -} - -func (TypeTest) TableName() string { return "type_test" } diff --git a/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__basic_table.snap b/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__basic_table.snap deleted file mode 100644 index fa115d76..00000000 --- a/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__basic_table.snap +++ /dev/null @@ -1,16 +0,0 @@ ---- -source: crates/vespertide-exporter/src/gorm/mod.rs -expression: result ---- -package models - -// User accounts -type Users struct { - // Primary key - ID int32 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` - Email string `gorm:"column:email;not null;unique;size:255" json:"email"` - Name *string `gorm:"column:name;type:text" json:"name"` - Active bool `gorm:"column:active;not null;default:true" json:"active"` -} - -func (Users) TableName() string { return "users" } diff --git a/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__composite_pk_nullable.snap b/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__composite_pk_nullable.snap deleted file mode 100644 index a70f0b44..00000000 --- a/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__composite_pk_nullable.snap +++ /dev/null @@ -1,16 +0,0 @@ ---- -source: crates/vespertide-exporter/src/gorm/mod.rs -expression: result ---- -package models - -type OrderItems struct { - OrderID int32 `gorm:"column:order_id;primaryKey;uniqueIndex:uq_order_items__order_product" json:"order_id"` - Order Orders `gorm:"foreignKey:OrderID" json:"-"` - ProductID int32 `gorm:"column:product_id;primaryKey;uniqueIndex:uq_order_items__order_product" json:"product_id"` - Product Products `gorm:"foreignKey:ProductID" json:"-"` - Quantity int32 `gorm:"column:quantity;not null" json:"quantity"` - Note *string `gorm:"column:note;type:text" json:"note"` -} - -func (OrderItems) TableName() string { return "order_items" } diff --git a/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__has_many_with_constraint.snap b/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__has_many_with_constraint.snap deleted file mode 100644 index fe836c1f..00000000 --- a/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__has_many_with_constraint.snap +++ /dev/null @@ -1,12 +0,0 @@ ---- -source: crates/vespertide-exporter/src/gorm/mod.rs -expression: result ---- -package models - -type Users struct { - ID int32 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` - Posts []Posts `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE,OnUpdate:RESTRICT" json:"-"` -} - -func (Users) TableName() string { return "users" } diff --git a/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__server_default_skipped.snap b/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__server_default_skipped.snap deleted file mode 100644 index cd226a79..00000000 --- a/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__server_default_skipped.snap +++ /dev/null @@ -1,17 +0,0 @@ ---- -source: crates/vespertide-exporter/src/gorm/mod.rs -expression: result ---- -package models - -import ( - "time" -) - -type Events struct { - ID int32 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` - CreatedAt time.Time `gorm:"column:created_at;not null" json:"created_at"` - Count int32 `gorm:"column:count;not null;default:0" json:"count"` -} - -func (Events) TableName() string { return "events" } diff --git a/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__table_with_foreign_key.snap b/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__table_with_foreign_key.snap deleted file mode 100644 index 37b7ce58..00000000 --- a/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__table_with_foreign_key.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: crates/vespertide-exporter/src/gorm/mod.rs -expression: result ---- -package models - -type Posts struct { - ID int32 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` - AuthorID int32 `gorm:"column:author_id;not null;index:ix_posts__author_id" json:"author_id"` - Author Users `gorm:"foreignKey:AuthorID;constraint:OnDelete:CASCADE" json:"-"` - Title string `gorm:"column:title;not null;type:text" json:"title"` -} - -func (Posts) TableName() string { return "posts" } diff --git a/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__table_with_integer_enum.snap b/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__table_with_integer_enum.snap deleted file mode 100644 index f1fae1b2..00000000 --- a/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__table_with_integer_enum.snap +++ /dev/null @@ -1,20 +0,0 @@ ---- -source: crates/vespertide-exporter/src/gorm/mod.rs -expression: result ---- -package models - -type PriorityLevel int - -const ( - PriorityLevelLow PriorityLevel = 0 - PriorityLevelMedium PriorityLevel = 10 - PriorityLevelHigh PriorityLevel = 20 -) - -type Tasks struct { - ID int32 `gorm:"column:id;primaryKey" json:"id"` - Priority PriorityLevel `gorm:"column:priority;not null" json:"priority"` -} - -func (Tasks) TableName() string { return "tasks" } diff --git a/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__table_with_jsonb_column.snap b/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__table_with_jsonb_column.snap deleted file mode 100644 index 73699dda..00000000 --- a/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__table_with_jsonb_column.snap +++ /dev/null @@ -1,17 +0,0 @@ ---- -source: crates/vespertide-exporter/src/gorm/mod.rs -expression: result ---- -package models - -import ( - "gorm.io/datatypes" -) - -type Documents struct { - ID int32 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` - Data datatypes.JSON `gorm:"column:data;not null;type:JSONB" json:"data"` - Meta datatypes.JSON `gorm:"column:meta;not null" json:"meta"` -} - -func (Documents) TableName() string { return "documents" } diff --git a/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__table_with_string_enum.snap b/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__table_with_string_enum.snap deleted file mode 100644 index 1fbaeb7c..00000000 --- a/crates/vespertide-exporter/src/gorm/tests/snapshots/vespertide_exporter__gorm__tests__table_with_string_enum.snap +++ /dev/null @@ -1,20 +0,0 @@ ---- -source: crates/vespertide-exporter/src/gorm/mod.rs -expression: result ---- -package models - -type OrderStatus string - -const ( - OrderStatusPending OrderStatus = "pending" - OrderStatusShipped OrderStatus = "shipped" - OrderStatusDelivered OrderStatus = "delivered" -) - -type Orders struct { - ID int32 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` - Status OrderStatus `gorm:"column:status;not null;default:'pending'" json:"status"` -} - -func (Orders) TableName() string { return "orders" } diff --git a/crates/vespertide-exporter/src/gorm/types.rs b/crates/vespertide-exporter/src/gorm/types.rs new file mode 100644 index 00000000..d38c1022 --- /dev/null +++ b/crates/vespertide-exporter/src/gorm/types.rs @@ -0,0 +1,169 @@ +use std::collections::HashMap; + +use super::render::exported_go_name; +use crate::utils::common::is_jsonb_custom_type; +use vespertide_core::schema::column::{ + ColumnType, ComplexColumnType, EnumValues, SimpleColumnType, +}; + +/// Track which Go imports are actually used to generate minimal import statements. +#[expect( + clippy::struct_excessive_bools, + reason = "four independent import-presence flags; enum would add verbosity without clarity" +)] +#[derive(Default)] +pub(super) struct UsedImports { + pub(super) needs_time: bool, + pub(super) needs_uuid: bool, + pub(super) needs_datatypes: bool, + pub(super) needs_decimal: bool, +} + +impl UsedImports { + pub(super) fn add_column_type(&mut self, col_type: &ColumnType) { + match col_type { + ColumnType::Simple(ty) => match ty { + SimpleColumnType::Date + | SimpleColumnType::Time + | SimpleColumnType::Timestamp + | SimpleColumnType::Timestamptz => { + self.needs_time = true; + } + SimpleColumnType::Uuid => { + self.needs_uuid = true; + } + SimpleColumnType::Json => { + self.needs_datatypes = true; + } + _ => {} + }, + ColumnType::Complex(ty) => { + if let ComplexColumnType::Numeric { .. } = ty { + self.needs_decimal = true; + } + if let ComplexColumnType::Custom { custom_type } = ty + && is_jsonb_custom_type(custom_type) + { + self.needs_datatypes = true; + } + } + } + } +} + +pub(super) fn go_type_for_column_mapped( + col_type: &ColumnType, + nullable: bool, + enum_map: &HashMap<&str, String>, +) -> String { + let base = match col_type { + ColumnType::Complex(ComplexColumnType::Enum { name, .. }) => enum_map + .get(name.as_str()) + .cloned() + .unwrap_or_else(|| exported_go_name(name)), + _ => go_base_type(col_type), + }; + if nullable { format!("*{base}") } else { base } +} + +/// Whether the column's Go field is a `string`, or an enum type declared over +/// one. +pub(super) fn is_go_string(col_type: &ColumnType) -> bool { + match col_type { + ColumnType::Complex(ComplexColumnType::Enum { values, .. }) => { + matches!(values, EnumValues::String(_)) + } + _ => go_base_type(col_type) == "string", + } +} + +fn go_base_type(col_type: &ColumnType) -> String { + match col_type { + ColumnType::Simple(ty) => match ty { + SimpleColumnType::SmallInt => "int16".to_string(), + SimpleColumnType::Integer => "int32".to_string(), + SimpleColumnType::BigInt => "int64".to_string(), + SimpleColumnType::Real => "float32".to_string(), + SimpleColumnType::DoublePrecision => "float64".to_string(), + SimpleColumnType::Text + | SimpleColumnType::Xml + | SimpleColumnType::Inet + | SimpleColumnType::Cidr + | SimpleColumnType::Macaddr + | SimpleColumnType::Interval => "string".to_string(), + SimpleColumnType::Boolean => "bool".to_string(), + SimpleColumnType::Date + | SimpleColumnType::Time + | SimpleColumnType::Timestamp + | SimpleColumnType::Timestamptz => "time.Time".to_string(), + SimpleColumnType::Bytea => "[]byte".to_string(), + SimpleColumnType::Uuid => "uuid.UUID".to_string(), + SimpleColumnType::Json => "datatypes.JSON".to_string(), + }, + ColumnType::Complex(ty) => match ty { + ComplexColumnType::Varchar { .. } | ComplexColumnType::Char { .. } => { + "string".to_string() + } + ComplexColumnType::Custom { custom_type } => { + if is_jsonb_custom_type(custom_type) { + "datatypes.JSON".to_string() + } else { + "string".to_string() + } + } + ComplexColumnType::Numeric { .. } => "decimal.Decimal".to_string(), + _ => unreachable!( + "ComplexColumnType is #[non_exhaustive]; all variants are matched above" + ), + }, + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use rstest::rstest; + use vespertide_core::schema::column::{ColumnType, ComplexColumnType, SimpleColumnType}; + + use super::go_type_for_column_mapped; + + #[rstest] + #[case(ColumnType::Simple(SimpleColumnType::SmallInt), false, "int16")] + #[case(ColumnType::Simple(SimpleColumnType::Integer), false, "int32")] + #[case(ColumnType::Simple(SimpleColumnType::BigInt), false, "int64")] + #[case(ColumnType::Simple(SimpleColumnType::Real), false, "float32")] + #[case( + ColumnType::Simple(SimpleColumnType::DoublePrecision), + false, + "float64" + )] + #[case(ColumnType::Simple(SimpleColumnType::Text), false, "string")] + #[case(ColumnType::Simple(SimpleColumnType::Boolean), false, "bool")] + #[case(ColumnType::Simple(SimpleColumnType::Timestamp), false, "time.Time")] + #[case(ColumnType::Simple(SimpleColumnType::Timestamptz), false, "time.Time")] + #[case(ColumnType::Simple(SimpleColumnType::Date), false, "time.Time")] + #[case(ColumnType::Simple(SimpleColumnType::Time), false, "time.Time")] + #[case(ColumnType::Simple(SimpleColumnType::Uuid), false, "uuid.UUID")] + #[case(ColumnType::Simple(SimpleColumnType::Json), false, "datatypes.JSON")] + #[case(ColumnType::Simple(SimpleColumnType::Bytea), false, "[]byte")] + #[case(ColumnType::Simple(SimpleColumnType::Inet), false, "string")] + #[case(ColumnType::Complex(ComplexColumnType::Varchar { length: 255 }), false, "string")] + #[case(ColumnType::Complex(ComplexColumnType::Numeric { precision: 10, scale: 2 }), false, "decimal.Decimal")] + #[case(ColumnType::Complex(ComplexColumnType::Custom { custom_type: "JSONB".into() }), false, "datatypes.JSON")] + #[case(ColumnType::Complex(ComplexColumnType::Custom { custom_type: "jsonb".into() }), false, "datatypes.JSON")] + #[case(ColumnType::Complex(ComplexColumnType::Custom { custom_type: "TEXT".into() }), false, "string")] + #[case(ColumnType::Simple(SimpleColumnType::Integer), true, "*int32")] + #[case(ColumnType::Simple(SimpleColumnType::Text), true, "*string")] + #[case(ColumnType::Simple(SimpleColumnType::Timestamp), true, "*time.Time")] + fn column_types_map_to_go_types( + #[case] col_type: ColumnType, + #[case] nullable: bool, + #[case] expected: &str, + ) { + assert_eq!( + go_type_for_column_mapped(&col_type, nullable, &HashMap::new()), + expected + ); + } +} diff --git a/crates/vespertide-exporter/src/lib.rs b/crates/vespertide-exporter/src/lib.rs index c860a920..0c845cfc 100644 --- a/crates/vespertide-exporter/src/lib.rs +++ b/crates/vespertide-exporter/src/lib.rs @@ -1,14 +1,16 @@ //! Helpers to convert `TableDef` models into ORM-specific representations -//! such as `SeaORM`, `SQLAlchemy`, `SQLModel`, JPA, Prisma, and Drizzle. +//! such as `SeaORM`, `SQLAlchemy`, `SQLModel`, JPA, Prisma, Drizzle, and GORM. mod constraint_scan; pub mod drizzle; mod enum_scan; +pub mod gorm; pub mod jpa; pub mod orm; mod parallel_config; pub mod prisma; pub mod python_naming; +mod scope_names; pub mod seaorm; pub mod sqlalchemy; pub mod sqlmodel; @@ -17,6 +19,7 @@ mod tests; mod utils; pub use drizzle::DrizzleExporter; +pub use gorm::GormExporter; pub use jpa::JpaExporter; pub use orm::{Orm, OrmExporter, render_entity, render_entity_with_schema}; pub use prisma::PrismaExporter; diff --git a/crates/vespertide-exporter/src/orm.rs b/crates/vespertide-exporter/src/orm.rs index c520b5fa..29f8ea45 100644 --- a/crates/vespertide-exporter/src/orm.rs +++ b/crates/vespertide-exporter/src/orm.rs @@ -1,8 +1,8 @@ use vespertide_core::TableDef; use crate::{ - drizzle::DrizzleExporter, jpa::JpaExporter, prisma::PrismaExporter, seaorm::SeaOrmExporter, - sqlalchemy::SqlAlchemyExporter, sqlmodel::SqlModelExporter, + drizzle::DrizzleExporter, gorm::GormExporter, jpa::JpaExporter, prisma::PrismaExporter, + seaorm::SeaOrmExporter, sqlalchemy::SqlAlchemyExporter, sqlmodel::SqlModelExporter, }; /// Supported ORM targets. @@ -17,6 +17,7 @@ pub enum Orm { Jpa, Prisma, Drizzle, + Gorm, } impl Orm { @@ -28,6 +29,7 @@ impl Orm { Orm::Jpa => "java", Orm::Prisma => "prisma", Orm::Drizzle => "ts", + Orm::Gorm => "go", } } } @@ -56,6 +58,7 @@ pub fn render_entity(orm: Orm, table: &TableDef) -> Result { Orm::Jpa => JpaExporter.render_entity(table), Orm::Prisma => PrismaExporter.render_entity(table), Orm::Drizzle => DrizzleExporter.render_entity(table), + Orm::Gorm => GormExporter.render_entity(table), } } @@ -72,6 +75,7 @@ pub fn render_entity_with_schema( Orm::Jpa => JpaExporter.render_entity_with_schema(table, schema), Orm::Prisma => PrismaExporter.render_entity_with_schema(table, schema), Orm::Drizzle => DrizzleExporter.render_entity_with_schema(table, schema), + Orm::Gorm => GormExporter.render_entity_with_schema(table, schema), } } @@ -88,6 +92,7 @@ mod tests { #[case::jpa(Orm::Jpa)] #[case::prisma(Orm::Prisma)] #[case::drizzle(Orm::Drizzle)] + #[case::gorm(Orm::Gorm)] fn dispatch_render_entity_succeeds(#[case] orm: Orm) { let table = basic_single_pk(); assert!(render_entity(orm, &table).is_ok()); @@ -100,6 +105,7 @@ mod tests { #[case::jpa(Orm::Jpa)] #[case::prisma(Orm::Prisma)] #[case::drizzle(Orm::Drizzle)] + #[case::gorm(Orm::Gorm)] fn dispatch_render_entity_with_schema_succeeds(#[case] orm: Orm) { let table = basic_single_pk(); let schema = vec![table.clone()]; @@ -113,6 +119,7 @@ mod tests { #[case::jpa(Orm::Jpa, "java")] #[case::prisma(Orm::Prisma, "prisma")] #[case::drizzle(Orm::Drizzle, "ts")] + #[case::gorm(Orm::Gorm, "go")] fn file_extension_matches_backend(#[case] orm: Orm, #[case] expected: &str) { assert_eq!(orm.file_extension(), expected); } @@ -126,6 +133,7 @@ mod tests { #[case::jpa("jpa", Orm::Jpa)] #[case::prisma("prisma", Orm::Prisma)] #[case::drizzle("drizzle", Orm::Drizzle)] + #[case::gorm("gorm", Orm::Gorm)] fn value_enum_parses_cli_name(#[case] input: &str, #[case] expected: Orm) { assert_eq!( clap::ValueEnum::from_str(input, false), diff --git a/crates/vespertide-exporter/src/python_naming.rs b/crates/vespertide-exporter/src/python_naming.rs index 6a5b4b29..92de8a03 100644 --- a/crates/vespertide-exporter/src/python_naming.rs +++ b/crates/vespertide-exporter/src/python_naming.rs @@ -1,14 +1,18 @@ -//! Shared naming helpers for the Python-targeted ORM exporters (SQLAlchemy, -//! SQLModel). Both backends share an identical, snake-case-aware -//! `to_pascal_case`. +//! Shared `to_pascal_case`: split on `_`, upper-case the first character of +//! each segment, keep the rest verbatim. SQLAlchemy, SQLModel, JPA, GORM and +//! the CLI's filename derivation all want exactly that rule, which +//! is a naming convention rather than a language feature — which is why the +//! Java and Go backends share it instead of carrying copies. //! //! Enum member names go through `vespertide_naming::to_screaming_snake_case` + //! `sanitize_identifier` instead — that pair is shared with the Prisma backend, //! so the case rule lives in `vespertide-naming` rather than here. //! -//! `seaorm` deliberately keeps its own `to_pascal_case` in -//! `seaorm/imports.rs` — that variant carries reserved-keyword guards and a -//! different allocation pattern and is NOT in scope for this consolidation. +//! `seaorm` keeps its own `to_pascal_case` in `seaorm/imports.rs`: that variant +//! also treats `-` as a separator and upper-cases with `to_ascii_uppercase` +//! rather than Unicode-aware `char::to_uppercase`, so the two are not +//! interchangeable. Reserved-keyword escaping is a separate concern, handled +//! by `seaorm::imports::sanitize_field_name`. /// Convert snake_case (or single-word) input to PascalCase. Splits on /// underscores, upper-cases the first character of each segment, and diff --git a/crates/vespertide-exporter/src/scope_names.rs b/crates/vespertide-exporter/src/scope_names.rs new file mode 100644 index 00000000..fbf67e9c --- /dev/null +++ b/crates/vespertide-exporter/src/scope_names.rs @@ -0,0 +1,258 @@ +//! Top-level names of a generated file that holds a whole schema in one scope. +//! +//! GORM writes every struct, enum type and enum constant into one Go package. +//! A table and an enum that share a name (`role` and `user.role`), two names +//! that fold onto +//! one identifier, or an enum constant that spells a struct (`Status` + `code` +//! next to `status_code`) would otherwise be declared twice. Names are claimed +//! here once for the whole schema and looked up by what they name, so every +//! reference agrees with its declaration. +//! +//! Drizzle's `drizzle::bindings` is a different claim order over a different +//! scope: it starts from the dialect's import symbols and the callback +//! parameters, claims `customType` helpers and `relations` consts as well, and +//! always qualifies an enum with its table. + +use std::collections::{HashMap, HashSet}; + +use vespertide_core::TableDef; + +use crate::enum_scan::collect_table_enums; +use crate::utils::common::claim_binding; + +#[derive(PartialEq, Eq, Hash)] +enum Named { + Table(String), + Enum(String, String), + Member(String, String, usize), +} + +#[derive(Default)] +pub(crate) struct ScopeNames { + names: HashMap, + taken: HashSet, +} + +impl ScopeNames { + /// Claim a name for every table, then for every table's enums. Tables go + /// first so a table always keeps its natural name. An enum keeps its bare + /// identifier only while no other table declares the same one and nothing + /// holds it yet; otherwise it is qualified with the name its table claimed. + pub(crate) fn collect( + schema: &[TableDef], + table_identifier: impl Fn(&str) -> String, + enum_identifier: impl Fn(&str) -> String, + ) -> Self { + let mut scope = Self::default(); + for table in schema { + scope.claim( + Named::Table(table.name.to_string()), + table_identifier(&table.name), + ); + } + + let shared = identifiers_shared_across_tables(schema, &enum_identifier); + for table in schema { + for (enum_name, _) in collect_table_enums(table) { + let bare = enum_identifier(enum_name); + let natural = if shared.contains(&bare) || scope.taken.contains(&bare) { + format!( + "{}{bare}", + scope.names[&Named::Table(table.name.to_string())] + ) + } else { + bare + }; + scope.claim( + Named::Enum(table.name.to_string(), enum_name.to_string()), + natural, + ); + } + } + scope + } + + /// Claim the `index`-th member of a table's enum, for a backend whose enum + /// members share the file's scope. + pub(crate) fn claim_member( + &mut self, + table: &str, + enum_name: &str, + index: usize, + natural: String, + ) { + self.claim( + Named::Member(table.to_string(), enum_name.to_string(), index), + natural, + ); + } + + /// The first claim for a key stands: two columns of one table may share an + /// enum, and a schema may list a table twice. + fn claim(&mut self, key: Named, natural: String) { + if !self.names.contains_key(&key) { + let name = claim_binding(natural, &mut self.taken); + self.names.insert(key, name); + } + } + + /// `None` for a table outside the schema the names were collected from — + /// a foreign key may point there — which callers answer with the natural + /// name. + pub(crate) fn table(&self, table: &str) -> Option<&str> { + self.names + .get(&Named::Table(table.to_string())) + .map(String::as_str) + } + + /// The type of an enum `table` declares. Only that table's own render asks, + /// and every render collects its names from a slice that holds the table: + /// the schema for a whole file, [`scope_of`] for a single table. + pub(crate) fn enum_type(&self, table: &str, enum_name: &str) -> &str { + &self.names[&Named::Enum(table.to_string(), enum_name.to_string())] + } + + /// The `index`-th member of an enum `table` declares, as claimed through + /// [`Self::claim_member`]. + pub(crate) fn member(&self, table: &str, enum_name: &str, index: usize) -> &str { + &self.names[&Named::Member(table.to_string(), enum_name.to_string(), index)] + } +} + +/// The tables whose names share a file with `table`'s: `schema` when it holds +/// the table, the table alone otherwise — a render without schema context +/// still claims the table's own names against each other. +pub(crate) fn scope_of<'a>(table: &'a TableDef, schema: &'a [TableDef]) -> &'a [TableDef] { + if schema.contains(table) { + schema + } else { + std::slice::from_ref(table) + } +} + +/// Identifiers more than one table of `schema` declares an enum under. Names +/// are compared after `identifier` has converted them, since distinct names +/// can collapse onto the same one (`doc_status` and `docStatus`). +fn identifiers_shared_across_tables( + schema: &[TableDef], + identifier: impl Fn(&str) -> String, +) -> HashSet { + let mut tables_declaring: HashMap = HashMap::new(); + for table in schema { + let declared: HashSet = collect_table_enums(table) + .into_iter() + .map(|(name, _)| identifier(name)) + .collect(); + for ident in declared { + *tables_declaring.entry(ident).or_default() += 1; + } + } + tables_declaring + .into_iter() + .filter(|(_, tables)| *tables > 1) + .map(|(ident, _)| ident) + .collect() +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + use vespertide_core::schema::column::{ColumnType, ComplexColumnType, EnumValues}; + use vespertide_core::{ColumnDef, TableDef}; + + use super::{ScopeNames, scope_of}; + use crate::python_naming::to_pascal_case; + + fn table(name: &str, enums: &[&str]) -> TableDef { + TableDef { + name: name.into(), + description: None, + columns: enums + .iter() + .map(|enum_name| { + let ty = ColumnType::Complex(ComplexColumnType::Enum { + name: (*enum_name).to_string(), + values: EnumValues::String(vec!["a".into()]), + }); + ColumnDef::new(*enum_name, ty, false) + }) + .collect(), + constraints: vec![], + } + } + + fn collect(schema: &[TableDef]) -> ScopeNames { + ScopeNames::collect(schema, to_pascal_case, to_pascal_case) + } + + #[rstest] + // Nothing else wants the identifier: the enum keeps it bare. + #[case::unique(&[("orders", &["status"][..])], "orders", "status", "Status")] + // A table of the same name holds it, whichever is declared first. + #[case::taken_by_a_later_table(&[("user", &["role"][..]), ("role", &[])], "user", "role", "UserRole")] + #[case::taken_by_an_earlier_table(&[("role", &[][..]), ("user", &["role"])], "user", "role", "UserRole")] + // Two tables declare it: both are qualified. + #[case::shared(&[("orders", &["status"][..]), ("tasks", &["status"])], "tasks", "status", "TasksStatus")] + // The qualified name is itself a table: numbered. + #[case::qualified_name_taken( + &[("orders", &["status"][..]), ("tasks", &["status"]), ("tasks_status", &[])], + "tasks", + "status", + "TasksStatus2" + )] + // Two names of one table fold onto one identifier: the second finds it + // held, so each still gets a type of its own. + #[case::folds_within_a_table(&[("docs", &["doc_status", "docStatus"][..])], "docs", "docStatus", "DocsDocStatus")] + // The qualifier is the name the table claimed, not the one it wanted. + #[case::qualified_by_a_numbered_table( + &[("user_data", &["status"][..]), ("userData", &["status"])], + "userData", + "status", + "UserData2Status" + )] + fn enums_are_named_clear_of_everything_else_in_the_scope( + #[case] schema: &[(&str, &[&str])], + #[case] table_name: &str, + #[case] enum_name: &str, + #[case] expected: &str, + ) { + let schema: Vec = schema + .iter() + .map(|(name, enums)| table(name, enums)) + .collect(); + assert_eq!(collect(&schema).enum_type(table_name, enum_name), expected); + } + + #[test] + fn tables_that_fold_onto_one_identifier_are_numbered() { + let names = collect(&[table("user_data", &[]), table("userData", &[])]); + assert_eq!(names.table("user_data"), Some("UserData")); + assert_eq!(names.table("userData"), Some("UserData2")); + } + + #[test] + fn members_share_the_scope_with_tables_and_enums() { + let mut names = collect(&[table("status_code", &[]), table("ticket", &["status"])]); + names.claim_member("ticket", "status", 0, "StatusCode".into()); + names.claim_member("ticket", "status", 1, "Status".into()); + assert_eq!(names.member("ticket", "status", 0), "StatusCode2"); + assert_eq!(names.member("ticket", "status", 1), "Status2"); + } + + #[test] + fn a_table_outside_the_schema_is_unknown() { + let names = collect(&[table("orders", &["status"])]); + assert_eq!(names.table("users"), None); + } + + /// A schema that does not hold the table is no scope for it. + #[test] + fn a_table_is_its_own_scope_outside_its_schema() { + let orders = table("orders", &["status"]); + let schema = [table("users", &[]), orders.clone()]; + let alone = std::slice::from_ref(&orders); + assert_eq!(scope_of(&orders, &schema), schema); + assert_eq!(scope_of(&orders, &schema[..1]), alone); + assert_eq!(scope_of(&orders, &[]), alone); + } +} diff --git a/crates/vespertide-exporter/src/tests/fixtures/identifiers.rs b/crates/vespertide-exporter/src/tests/fixtures/identifiers.rs new file mode 100644 index 00000000..556e506c --- /dev/null +++ b/crates/vespertide-exporter/src/tests/fixtures/identifiers.rs @@ -0,0 +1,115 @@ +//! Names that collide, or need escaping, once mapped into a host language. + +use vespertide_core::schema::column::SimpleColumnType; +use vespertide_core::schema::constraint::TableConstraint; +use vespertide_core::{ReferenceAction, TableDef}; + +use super::{col, fk, nullable_simple, pk, simple, string_enum}; + +/// Relation field names that run into a struct's own columns: a composite FK +/// whose target struct name is already taken by two columns (`order_regions`, +/// `order_regions2`), a has-many whose pluralized source name is a column +/// (`users.posts`), a belongs-to that spells the `TableName` method GORM gives +/// every struct (`posts.table_name_id`), and a self-reference, whose reverse +/// side only appears when the table is rendered with itself in the schema. +pub(crate) fn relation_field_names() -> Vec { + let order_regions = TableDef { + name: "order_regions".into(), + description: None, + columns: vec![ + simple("order_id", SimpleColumnType::Integer), + simple("region_id", SimpleColumnType::Integer), + ], + constraints: vec![pk(&["order_id", "region_id"])], + }; + let order_items = TableDef { + name: "order_items".into(), + description: None, + columns: vec![ + simple("id", SimpleColumnType::Integer), + simple("order_id", SimpleColumnType::Integer), + simple("region_id", SimpleColumnType::Integer), + simple("order_regions", SimpleColumnType::Text), + simple("order_regions2", SimpleColumnType::Text), + ], + constraints: vec![ + pk(&["id"]), + TableConstraint::ForeignKey { + name: None, + columns: vec!["order_id".into(), "region_id".into()], + ref_table: "order_regions".into(), + ref_columns: vec!["order_id".into(), "region_id".into()], + on_delete: Some(ReferenceAction::Cascade), + on_update: Some(ReferenceAction::Restrict), + orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), + }, + ], + }; + let users = TableDef { + name: "users".into(), + description: None, + columns: vec![ + simple("id", SimpleColumnType::Integer), + simple("posts", SimpleColumnType::Text), + ], + constraints: vec![pk(&["id"])], + }; + let posts = TableDef { + name: "posts".into(), + description: None, + columns: vec![ + simple("id", SimpleColumnType::Integer), + simple("user_id", SimpleColumnType::Integer), + simple("table_name_id", SimpleColumnType::Integer), + ], + constraints: vec![ + pk(&["id"]), + fk(&["user_id"], "users", &["id"]), + fk(&["table_name_id"], "categories", &["id"]), + ], + }; + let categories = TableDef { + name: "categories".into(), + description: None, + columns: vec![ + simple("id", SimpleColumnType::Integer), + nullable_simple("parent_id", SimpleColumnType::Integer), + ], + constraints: vec![pk(&["id"]), fk(&["parent_id"], "categories", &["id"])], + }; + [order_regions, order_items, users, posts, categories] + .into_iter() + .map(|t| t.normalize().expect("relation_field_names normalizes")) + .collect() +} + +/// Two tables declare an enum with the same name and different values. A +/// backend whose enum types share one namespace (GORM's package, Prisma's +/// single file) has to qualify the type names by table. +pub(crate) fn enum_name_shared_across_tables() -> Vec { + let orders = TableDef { + name: "orders".into(), + description: None, + columns: vec![ + simple("id", SimpleColumnType::Integer), + col("status", string_enum("status", &["pending", "shipped"])), + ], + constraints: vec![pk(&["id"])], + }; + let tasks = TableDef { + name: "tasks".into(), + description: None, + columns: vec![ + simple("id", SimpleColumnType::Integer), + col("status", string_enum("status", &["todo", "done"])), + ], + constraints: vec![pk(&["id"])], + }; + [orders, tasks] + .into_iter() + .map(|t| { + t.normalize() + .expect("enum_name_shared_across_tables normalizes") + }) + .collect() +} diff --git a/crates/vespertide-exporter/src/tests/fixtures/mod.rs b/crates/vespertide-exporter/src/tests/fixtures/mod.rs index 7f3797ab..8f758fad 100644 --- a/crates/vespertide-exporter/src/tests/fixtures/mod.rs +++ b/crates/vespertide-exporter/src/tests/fixtures/mod.rs @@ -11,6 +11,12 @@ use vespertide_core::{ mod collisions; pub(crate) use collisions::binding_collisions; +mod reference_actions; +pub(crate) use reference_actions::reference_actions; + +mod identifiers; +pub(crate) use identifiers::{enum_name_shared_across_tables, relation_field_names}; + pub(crate) fn col(name: &str, ty: ColumnType) -> ColumnDef { ColumnDef::new(name, ty, false) } @@ -23,7 +29,7 @@ pub(crate) fn simple(name: &str, ty: SimpleColumnType) -> ColumnDef { col(name, ColumnType::Simple(ty)) } -fn nullable_simple(name: &str, ty: SimpleColumnType) -> ColumnDef { +pub(crate) fn nullable_simple(name: &str, ty: SimpleColumnType) -> ColumnDef { nullable_col(name, ColumnType::Simple(ty)) } @@ -331,7 +337,7 @@ pub(crate) fn enum_special_values() -> TableDef { ) } -fn string_enum(name: &str, values: &[&str]) -> ColumnType { +pub(crate) fn string_enum(name: &str, values: &[&str]) -> ColumnType { ColumnType::Complex(ComplexColumnType::Enum { name: name.into(), values: EnumValues::String(values.iter().copied().map(Into::into).collect()), @@ -767,6 +773,19 @@ pub(crate) fn json_default() -> TableDef { ) } +/// A default carrying `;`, which GORM's tag syntax cannot hold. +pub(crate) fn semicolon_default() -> TableDef { + table( + "notes", + vec![ + simple("id", SimpleColumnType::Integer), + simple("note", SimpleColumnType::Text).default("'a;b'".into()), + simple("body", SimpleColumnType::Text), + ], + vec![pk(&["id"])], + ) +} + pub(crate) fn self_referencing_fk() -> TableDef { let raw = TableDef { name: "employees".into(), diff --git a/crates/vespertide-exporter/src/tests/fixtures/reference_actions.rs b/crates/vespertide-exporter/src/tests/fixtures/reference_actions.rs new file mode 100644 index 00000000..2ce6f757 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/fixtures/reference_actions.rs @@ -0,0 +1,86 @@ +//! Foreign keys carrying both referential actions. + +use vespertide_core::schema::column::SimpleColumnType; +use vespertide_core::schema::constraint::TableConstraint; +use vespertide_core::{ReferenceAction, TableDef}; + +use super::{nullable_simple, pk, simple}; + +/// Foreign keys that set `ON UPDATE` as well as `ON DELETE`, one action each. +/// GORM, Prisma and Drizzle render both and the remaining four drop them +/// — a spread this fixture pins. Between the two children every action +/// but `SET NULL` (pinned by `self_referencing_fk`) appears, each paired with a +/// different one so a backend that emits one in the other's place is visible; +/// `comments.post_id` is nullable so a relation on a nullable key carries +/// actions too. `SET DEFAULT` appears with a column default +/// (`comments.author_id`) and without one. +pub(crate) fn reference_actions() -> Vec { + let users = TableDef { + name: "users".into(), + description: None, + columns: vec![simple("id", SimpleColumnType::Integer)], + constraints: vec![pk(&["id"])], + }; + let posts = TableDef { + name: "posts".into(), + description: None, + columns: vec![ + simple("id", SimpleColumnType::Integer), + simple("user_id", SimpleColumnType::Integer), + ], + constraints: vec![ + pk(&["id"]), + fk_with_actions( + "user_id", + "users", + ReferenceAction::Cascade, + ReferenceAction::Restrict, + ), + ], + }; + let comments = TableDef { + name: "comments".into(), + description: None, + columns: vec![ + simple("id", SimpleColumnType::Integer), + nullable_simple("post_id", SimpleColumnType::Integer), + simple("author_id", SimpleColumnType::Integer).default("1".into()), + ], + constraints: vec![ + pk(&["id"]), + fk_with_actions( + "post_id", + "posts", + ReferenceAction::SetDefault, + ReferenceAction::NoAction, + ), + fk_with_actions( + "author_id", + "users", + ReferenceAction::SetDefault, + ReferenceAction::Cascade, + ), + ], + }; + [users, posts, comments] + .into_iter() + .map(|t| t.normalize().expect("reference_actions normalizes")) + .collect() +} + +fn fk_with_actions( + column: &str, + ref_table: &str, + on_delete: ReferenceAction, + on_update: ReferenceAction, +) -> TableConstraint { + TableConstraint::ForeignKey { + name: None, + columns: vec![column.into()], + ref_table: ref_table.into(), + ref_columns: vec!["id".into()], + on_delete: Some(on_delete), + on_update: Some(on_update), + orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), + } +} diff --git a/crates/vespertide-exporter/src/tests/fixtures/schemas.rs b/crates/vespertide-exporter/src/tests/fixtures/schemas.rs index 691b7ce2..f5ede44b 100644 --- a/crates/vespertide-exporter/src/tests/fixtures/schemas.rs +++ b/crates/vespertide-exporter/src/tests/fixtures/schemas.rs @@ -131,6 +131,26 @@ pub(crate) fn schema_scenario(name: &str) -> (TableDef, Vec) { &["created_by_user_id", "updated_by_user_id"], true, ), + // A junction whose target pluralizes to a keyword (`pass`), a junction + // name that needs sanitizing, and a scalar column already named after + // the other target (`tags`). + "one_to_one_source" => { + let (_, schema) = reverse_user_schema("profile", &["user_id"], true); + (schema[1].clone(), schema) + } + // A one-to-one whose key is the source's whole primary key, rendered + // from the target: the reverse side holds at most one row. + "one_to_one_shared_primary_key" => { + let user = + table_with_named_pk("user", vec![simple("id", SimpleColumnType::Uuid)], &["id"]); + let profile = table_with_fk_constraints( + "profile", + vec![simple("user_id", SimpleColumnType::Uuid)], + &["user_id"], + vec![(vec!["user_id"], "user", vec!["id"])], + ); + (user.clone(), vec![user, profile]) + } "composite_and_single_fk_same_target" => { let target = table( "target", diff --git a/crates/vespertide-exporter/src/tests/mod.rs b/crates/vespertide-exporter/src/tests/mod.rs index 1c25fe0e..b7b6f901 100644 --- a/crates/vespertide-exporter/src/tests/mod.rs +++ b/crates/vespertide-exporter/src/tests/mod.rs @@ -23,10 +23,10 @@ fn orm_label(orm: Orm) -> String { } /// Dispatch the per-ORM **multi-table** entry point so the cross-ORM -/// `orm_cases!(multi ...)` arm renders a `Vec` schema for all six +/// `orm_cases!(multi ...)` arm renders a `Vec` schema for all seven /// ORMs through a single call. JPA's `render_entities` returns `Vec` /// (one entry per entity); we join with `"\n"` to match the -/// `String`-returning shape of the other four. +/// `String`-returning shape of the other six. fn render_schema(orm: Orm, schema: &[TableDef]) -> Result { match orm { Orm::SeaOrm => crate::seaorm::export(schema), @@ -35,6 +35,7 @@ fn render_schema(orm: Orm, schema: &[TableDef]) -> Result { Orm::Jpa => crate::jpa::render_entities(schema).map(|entities| entities.join("\n")), Orm::Prisma => crate::prisma::export(schema), Orm::Drizzle => crate::drizzle::export(schema), + Orm::Gorm => crate::gorm::export(schema), } } @@ -49,6 +50,7 @@ macro_rules! orm_cases { #[case::jpa(Orm::Jpa)] #[case::prisma(Orm::Prisma)] #[case::drizzle(Orm::Drizzle)] + #[case::gorm(Orm::Gorm)] fn $test_name(#[case] orm: Orm) { let table = $fixture(); let rendered = render_entity(orm, &table).unwrap(); @@ -67,6 +69,7 @@ macro_rules! orm_cases { #[case::jpa(Orm::Jpa)] #[case::prisma(Orm::Prisma)] #[case::drizzle(Orm::Drizzle)] + #[case::gorm(Orm::Gorm)] fn $test_name(#[case] orm: Orm) { let schema: Vec = $fixture(); let rendered = render_schema(orm, &schema).unwrap(); @@ -255,6 +258,11 @@ orm_cases!( "json_default", fixtures::json_default ); +orm_cases!( + semicolon_default_snapshot, + "semicolon_default", + fixtures::semicolon_default +); orm_cases!( self_referencing_fk_snapshot, "self_referencing_fk", @@ -292,7 +300,7 @@ orm_cases!( ); // Cross-ORM comparison of identifier escaping. Each language starts identifiers // differently — Prisma and Pydantic reject a leading `_`, the rest accept it — -// so the six snapshots must differ, and every one has to carry the original +// so the eight snapshots must differ, and every one has to carry the original // name (`@@map` / `@map`, `column_name`, the positional column name, // `sa_column_kwargs`, `@Table`/`@Column`). orm_cases!( @@ -320,9 +328,11 @@ orm_cases!( fixtures::non_identifier_relation_names ); // A composite FK becomes a relation only where the backend can express one -// (`SeaORM`'s tuple `from`/`to`, Prisma's multi-column `fields`/`references`); -// the Python backends keep it as a `ForeignKeyConstraint` and JPA currently -// drops it, so the six outputs disagree in a way worth pinning. +// (`SeaORM`'s tuple `from`/`to`, Prisma's multi-column `fields`/`references`, +// Drizzle's `foreignKey({columns, foreignColumns})` plus a `one(...)` relation, +// GORM's comma-separated `foreignKey`/`references`); SQLAlchemy and SQLModel +// keep it as a `ForeignKeyConstraint` and JPA currently drops it, so the seven +// outputs disagree in a way worth pinning. orm_cases!( multi composite_fk_relation_snapshot, "composite_fk_relation", @@ -376,12 +386,27 @@ orm_cases!( "binding_collisions", fixtures::binding_collisions ); +// Every referential action, `ON UPDATE` included, across both nullabilities. +orm_cases!( + multi reference_actions_snapshot, + "reference_actions", + fixtures::reference_actions +); +orm_cases!( + multi relation_field_names_snapshot, + "relation_field_names", + fixtures::relation_field_names +); +orm_cases!( + multi enum_name_shared_across_tables_snapshot, + "enum_name_shared_across_tables", + fixtures::enum_name_shared_across_tables +); /// Dispatch the per-ORM `to_pascal_case` helper from a single entry point so /// the cross-ORM consolidation test can exercise every implementation without -/// leaking the helper as a generally-public crate API. Prisma has no local -/// implementation — it calls `vespertide_naming::to_pascal_case` directly, so -/// this arm exercises the shared crate helper. +/// leaking a backend's private helper as a crate-public API. The backends that +/// have no local implementation name the shared helper they delegate to. fn to_pascal_case_for(orm: Orm, s: &str) -> String { match orm { Orm::SeaOrm => crate::seaorm::to_pascal_case_for_tests(s), @@ -389,20 +414,21 @@ fn to_pascal_case_for(orm: Orm, s: &str) -> String { Orm::SqlModel => crate::sqlmodel::to_pascal_case_for_tests(s), Orm::Jpa => crate::jpa::to_pascal_case_for_tests(s), Orm::Prisma | Orm::Drizzle => vespertide_naming::to_pascal_case(s), + Orm::Gorm => crate::python_naming::to_pascal_case(s), } } /// Cross-ORM `to_pascal_case` consolidation. Inputs in this matrix are /// restricted to ASCII with `_` as the only separator — the subset where all -/// six ORM implementations agree. +/// seven ORM implementations agree. /// /// Divergences intentionally NOT covered here: /// * `-` as separator: `SeaORM`, Prisma and Drizzle treat it as a separator -/// (the latter two via `vespertide_naming`), the other three ORMs leave it +/// (the latter two via `vespertide_naming`), the other four ORMs leave it /// intact (their splits operate on `_` only). -/// * Non-ASCII characters: `SeaORM` and Prisma use `to_ascii_uppercase`, the -/// others use `to_uppercase` (Unicode-aware). -/// These divergences are exercised in the per-ORM `tests.rs` files where +/// * Non-ASCII characters: `SeaORM`, Prisma and Drizzle use +/// `to_ascii_uppercase`, the other four use `to_uppercase` (Unicode-aware). +/// These divergences are exercised in each backend's own test module where /// applicable. #[rstest] #[case::seaorm(Orm::SeaOrm)] @@ -411,6 +437,7 @@ fn to_pascal_case_for(orm: Orm, s: &str) -> String { #[case::jpa(Orm::Jpa)] #[case::prisma(Orm::Prisma)] #[case::drizzle(Orm::Drizzle)] +#[case::gorm(Orm::Gorm)] fn to_pascal_case_shared_semantics( #[values( ("", ""), @@ -422,6 +449,8 @@ fn to_pascal_case_shared_semantics( ("user_id", "UserId"), ("a_b_c", "ABC"), ("a__b", "AB"), + ("_leading", "Leading"), + ("trailing_", "Trailing"), ("order_item", "OrderItem"), ("user_profile_image", "UserProfileImage") )] @@ -439,6 +468,7 @@ fn to_pascal_case_shared_semantics( #[case::jpa(Orm::Jpa)] #[case::prisma(Orm::Prisma)] #[case::drizzle(Orm::Drizzle)] +#[case::gorm(Orm::Gorm)] fn render_entity_with_schema_snapshots( #[values( "many_to_many_article", @@ -455,7 +485,9 @@ fn render_entity_with_schema_snapshots( "multiple_reverse_relations", "dual_reverse_relations", "triple_reverse_relations", - "multiple_has_one_relations" + "multiple_has_one_relations", + "one_to_one_source", + "one_to_one_shared_primary_key" )] scenario: &str, #[case] orm: Orm, @@ -466,3 +498,9 @@ fn render_entity_with_schema_snapshots( assert_snapshot!(rendered); }); } + +#[test] +#[should_panic(expected = "unknown schema scenario nonexistent_scenario")] +fn schema_scenario_panics_on_unknown_name() { + fixtures::schema_scenario("nonexistent_scenario"); +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__all_simple_types_snapshot@all_simple_types_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__all_simple_types_snapshot@all_simple_types_Gorm.snap new file mode 100644 index 00000000..384e300c --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__all_simple_types_snapshot@all_simple_types_Gorm.snap @@ -0,0 +1,36 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/datatypes" +) + +type AllTypes struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Small int16 `gorm:"column:small;not null" json:"small"` + Big int64 `gorm:"column:big;not null" json:"big"` + RealNum float32 `gorm:"column:real_num;not null" json:"real_num"` + DoubleNum float64 `gorm:"column:double_num;not null" json:"double_num"` + TextCol string `gorm:"column:text_col;not null;type:text" json:"text_col"` + BoolCol bool `gorm:"column:bool_col;not null" json:"bool_col"` + DateCol time.Time `gorm:"column:date_col;not null;type:date" json:"date_col"` + TimeCol time.Time `gorm:"column:time_col;not null;type:time" json:"time_col"` + TsCol time.Time `gorm:"column:ts_col;not null" json:"ts_col"` + TstzCol time.Time `gorm:"column:tstz_col;not null" json:"tstz_col"` + IntervalCol string `gorm:"column:interval_col;not null;type:interval" json:"interval_col"` + ByteaCol []byte `gorm:"column:bytea_col;not null" json:"bytea_col"` + UuidCol uuid.UUID `gorm:"column:uuid_col;not null;type:uuid" json:"uuid_col"` + JsonCol datatypes.JSON `gorm:"column:json_col;not null" json:"json_col"` + InetCol string `gorm:"column:inet_col;not null;type:inet" json:"inet_col"` + CidrCol string `gorm:"column:cidr_col;not null;type:cidr" json:"cidr_col"` + MacaddrCol string `gorm:"column:macaddr_col;not null;type:macaddr" json:"macaddr_col"` + XmlCol string `gorm:"column:xml_col;not null;type:xml" json:"xml_col"` +} + +func (AllTypes) TableName() string { return "all_types" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_single_pk_snapshot@basic_single_pk_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_single_pk_snapshot@basic_single_pk_Gorm.snap new file mode 100644 index 00000000..fbb03960 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_single_pk_snapshot@basic_single_pk_Gorm.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type Users struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + DisplayName *string `gorm:"column:display_name;type:text" json:"display_name"` +} + +func (Users) TableName() string { return "users" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_table_with_description_snapshot@basic_table_with_description_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_table_with_description_snapshot@basic_table_with_description_Gorm.snap new file mode 100644 index 00000000..f4bc41a9 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_table_with_description_snapshot@basic_table_with_description_Gorm.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +// User accounts table +type Users struct { + // Primary key + ID int32 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + // User email address + Email string `gorm:"column:email;not null;unique;type:text" json:"email"` + Name *string `gorm:"column:name;type:text" json:"name"` +} + +func (Users) TableName() string { return "users" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__binding_collisions_snapshot@binding_collisions_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__binding_collisions_snapshot@binding_collisions_Gorm.snap new file mode 100644 index 00000000..8c13c5f8 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__binding_collisions_snapshot@binding_collisions_Gorm.snap @@ -0,0 +1,34 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type UserRelations struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Kind string `gorm:"column:kind;not null;type:integer" json:"kind"` +} + +func (UserRelations) TableName() string { return "user_relations" } + +type User struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Posts []Posts `gorm:"foreignKey:UserID" json:"-"` +} + +func (User) TableName() string { return "user" } + +type Sql struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Amount int32 `gorm:"column:amount;not null" json:"amount"` +} + +func (Sql) TableName() string { return "sql" } + +type Posts struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + UserID int32 `gorm:"column:user_id;not null" json:"user_id"` + User *User `gorm:"foreignKey:UserID" json:"-"` +} + +func (Posts) TableName() string { return "posts" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__complex_types_snapshot@complex_types_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__complex_types_snapshot@complex_types_Gorm.snap new file mode 100644 index 00000000..4da498ba --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__complex_types_snapshot@complex_types_Gorm.snap @@ -0,0 +1,19 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +import ( + "github.com/shopspring/decimal" +) + +type ComplexTypes struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + VarcharCol string `gorm:"column:varchar_col;not null;size:100" json:"varchar_col"` + CharCol string `gorm:"column:char_col;not null;type:char(10)" json:"char_col"` + NumericCol decimal.Decimal `gorm:"column:numeric_col;not null;type:numeric(10,2)" json:"numeric_col"` + CustomCol string `gorm:"column:custom_col;not null;type:CUSTOM_TYPE" json:"custom_col"` +} + +func (ComplexTypes) TableName() string { return "complex_types" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_constraints_snapshot@composite_constraints_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_constraints_snapshot@composite_constraints_Gorm.snap new file mode 100644 index 00000000..41b71949 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_constraints_snapshot@composite_constraints_Gorm.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type OrderItems struct { + OrderID int32 `gorm:"column:order_id;primaryKey;index:ix_order_items__ix_order_items__order_id;uniqueIndex:uq_order_items__uq_order_items__order_product" json:"order_id"` + Order *Orders `gorm:"foreignKey:OrderID" json:"-"` + ProductID int32 `gorm:"column:product_id;primaryKey;uniqueIndex:uq_order_items__uq_order_items__order_product" json:"product_id"` + Product *Products `gorm:"foreignKey:ProductID" json:"-"` + Quantity int32 `gorm:"column:quantity;not null" json:"quantity"` +} + +func (OrderItems) TableName() string { return "order_items" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_fk_relation_snapshot@composite_fk_relation_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_fk_relation_snapshot@composite_fk_relation_Gorm.snap new file mode 100644 index 00000000..2361d5fd --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_fk_relation_snapshot@composite_fk_relation_Gorm.snap @@ -0,0 +1,23 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type Orders struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Version int32 `gorm:"column:version;primaryKey" json:"version"` + LineItems []LineItems `gorm:"foreignKey:OrderID,OrderVersion;references:ID,Version" json:"-"` +} + +func (Orders) TableName() string { return "orders" } + +type LineItems struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + OrderID int32 `gorm:"column:order_id;not null" json:"order_id"` + OrderVersion int32 `gorm:"column:order_version;not null" json:"order_version"` + Sku string `gorm:"column:sku;not null;type:text" json:"sku"` + Orders *Orders `gorm:"foreignKey:OrderID,OrderVersion;references:ID,Version" json:"-"` +} + +func (LineItems) TableName() string { return "line_items" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_index_snapshot@composite_index_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_index_snapshot@composite_index_Gorm.snap new file mode 100644 index 00000000..937f2ec1 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_index_snapshot@composite_index_Gorm.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type CompositeIndex struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + TenantID int32 `gorm:"column:tenant_id;not null;index:ix_composite_index__idx_tenant_name" json:"tenant_id"` + Name string `gorm:"column:name;not null;type:text;index:ix_composite_index__idx_tenant_name" json:"name"` +} + +func (CompositeIndex) TableName() string { return "composite_index" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_pk_snapshot@composite_pk_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_pk_snapshot@composite_pk_Gorm.snap new file mode 100644 index 00000000..defdd2ad --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_pk_snapshot@composite_pk_Gorm.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type Accounts struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + TenantID int64 `gorm:"column:tenant_id;primaryKey" json:"tenant_id"` +} + +func (Accounts) TableName() string { return "accounts" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_primary_key_snapshot@composite_primary_key_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_primary_key_snapshot@composite_primary_key_Gorm.snap new file mode 100644 index 00000000..e3eabf08 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_primary_key_snapshot@composite_primary_key_Gorm.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type Membership struct { + TenantID int32 `gorm:"column:tenant_id;primaryKey" json:"tenant_id"` + UserID int32 `gorm:"column:user_id;primaryKey" json:"user_id"` + Role string `gorm:"column:role;not null;type:text" json:"role"` +} + +func (Membership) TableName() string { return "membership" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_constraint_snapshot@composite_unique_constraint_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_constraint_snapshot@composite_unique_constraint_Gorm.snap new file mode 100644 index 00000000..7eab50c0 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_constraint_snapshot@composite_unique_constraint_Gorm.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type AccountAliases struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + TenantID int32 `gorm:"column:tenant_id;not null;uniqueIndex:uq_account_aliases__uq_account_aliases__tenant_slug" json:"tenant_id"` + Slug string `gorm:"column:slug;not null;type:text;uniqueIndex:uq_account_aliases__uq_account_aliases__tenant_slug" json:"slug"` +} + +func (AccountAliases) TableName() string { return "account_aliases" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_snapshot@composite_unique_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_snapshot@composite_unique_Gorm.snap new file mode 100644 index 00000000..fb9800f7 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_snapshot@composite_unique_Gorm.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type CompositeUnique struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + TenantID int32 `gorm:"column:tenant_id;not null;uniqueIndex:uq_composite_unique__uq_tenant_name" json:"tenant_id"` + Name string `gorm:"column:name;not null;type:text;uniqueIndex:uq_composite_unique__uq_tenant_name" json:"name"` +} + +func (CompositeUnique) TableName() string { return "composite_unique" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__defaults_snapshot@defaults_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__defaults_snapshot@defaults_Gorm.snap new file mode 100644 index 00000000..e3bbb610 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__defaults_snapshot@defaults_Gorm.snap @@ -0,0 +1,14 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type Articles struct { + ID int32 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + Published bool `gorm:"column:published;not null;default:false" json:"published"` + ViewCount int32 `gorm:"column:view_count;not null;default:0" json:"view_count"` + Status string `gorm:"column:status;not null;type:text;default:'draft'" json:"status"` +} + +func (Articles) TableName() string { return "articles" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_multiple_columns_snapshot@enum_multiple_columns_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_multiple_columns_snapshot@enum_multiple_columns_Gorm.snap new file mode 100644 index 00000000..050c4e1c --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_multiple_columns_snapshot@enum_multiple_columns_Gorm.snap @@ -0,0 +1,29 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type ProductCategory string + +const ( + ProductCategoryElectronics ProductCategory = "electronics" + ProductCategoryClothing ProductCategory = "clothing" + ProductCategoryFood ProductCategory = "food" +) + +type AvailabilityStatus string + +const ( + AvailabilityStatusInStock AvailabilityStatus = "in_stock" + AvailabilityStatusOutOfStock AvailabilityStatus = "out_of_stock" + AvailabilityStatusPreOrder AvailabilityStatus = "pre_order" +) + +type Products struct { + ID int32 `gorm:"column:id;not null" json:"id"` + Category ProductCategory `gorm:"column:category;not null" json:"category"` + Availability AvailabilityStatus `gorm:"column:availability;not null" json:"availability"` +} + +func (Products) TableName() string { return "products" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_Drizzle_pg.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_Drizzle_pg.snap new file mode 100644 index 00000000..62956756 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_Drizzle_pg.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +export const ordersStatus = pgEnum("orders_status", ["pending", "shipped"]); + +export const orders = pgTable("orders", { + id: integer("id").primaryKey(), + status: ordersStatus("status").notNull(), +}); + +export const tasksStatus = pgEnum("tasks_status", ["todo", "done"]); + +export const tasks = pgTable("tasks", { + id: integer("id").primaryKey(), + status: tasksStatus("status").notNull(), +}); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_Gorm.snap new file mode 100644 index 00000000..e2f1bfd1 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_Gorm.snap @@ -0,0 +1,33 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type OrdersStatus string + +const ( + OrdersStatusPending OrdersStatus = "pending" + OrdersStatusShipped OrdersStatus = "shipped" +) + +type Orders struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Status OrdersStatus `gorm:"column:status;not null" json:"status"` +} + +func (Orders) TableName() string { return "orders" } + +type TasksStatus string + +const ( + TasksStatusTodo TasksStatus = "todo" + TasksStatusDone TasksStatus = "done" +) + +type Tasks struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Status TasksStatus `gorm:"column:status;not null" json:"status"` +} + +func (Tasks) TableName() string { return "tasks" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_Jpa.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_Jpa.snap new file mode 100644 index 00000000..49c30405 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_Jpa.snap @@ -0,0 +1,49 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +import jakarta.persistence.*; + +enum Status { + pending, + shipped; +} + +@Entity +@Table(name = "orders") +public class Orders { + + @Id + @Column(name = "id") + private Integer id; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private Status status; + + protected Orders() { + } +} + +import jakarta.persistence.*; + +enum Status { + todo, + done; +} + +@Entity +@Table(name = "tasks") +public class Tasks { + + @Id + @Column(name = "id") + private Integer id; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private Status status; + + protected Tasks() { + } +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_Prisma.snap new file mode 100644 index 00000000..e09beade --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_Prisma.snap @@ -0,0 +1,27 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +enum OrdersStatus { + PENDING @map("pending") + SHIPPED @map("shipped") +} + +model Orders { + id Int @id + status OrdersStatus + + @@map("orders") +} + +enum TasksStatus { + TODO @map("todo") + DONE @map("done") +} + +model Tasks { + id Int @id + status TasksStatus + + @@map("tasks") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_SeaOrm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_SeaOrm.snap new file mode 100644 index 00000000..c3fac09e --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_SeaOrm.snap @@ -0,0 +1,54 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize, vespera::Schema)] +#[serde(rename_all = "camelCase")] +#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "orders_status")] +pub enum Status { + #[sea_orm(string_value = "pending")] + Pending, + #[sea_orm(string_value = "shipped")] + Shipped, +} + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "orders")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub status: Status, +} + +vespera::schema_type!(Schema from Model, name = "OrdersSchema"); +impl ActiveModelBehavior for ActiveModel {} + + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize, vespera::Schema)] +#[serde(rename_all = "camelCase")] +#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "tasks_status")] +pub enum Status { + #[sea_orm(string_value = "todo")] + Todo, + #[sea_orm(string_value = "done")] + Done, +} + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "tasks")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub status: Status, +} + +vespera::schema_type!(Schema from Model, name = "TasksSchema"); +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_SqlAlchemy.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_SqlAlchemy.snap new file mode 100644 index 00000000..11574b9b --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_SqlAlchemy.snap @@ -0,0 +1,31 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +import enum + +from sqlalchemy import Enum, Integer +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Status(str, enum.Enum): + PENDING = "pending" + SHIPPED = "shipped" + +class Orders(DeclarativeBase): + __tablename__ = "orders" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + status: Mapped[Status] = mapped_column(Enum(Status), nullable=False) + +class Status(str, enum.Enum): + TODO = "todo" + DONE = "done" + +class Tasks(DeclarativeBase): + __tablename__ = "tasks" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + status: Mapped[Status] = mapped_column(Enum(Status), nullable=False) diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_SqlModel.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_SqlModel.snap new file mode 100644 index 00000000..b079eb52 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_SqlModel.snap @@ -0,0 +1,30 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +import enum + +from sqlmodel import Field, SQLModel + + +class Status(str, enum.Enum): + PENDING = "pending" + SHIPPED = "shipped" + +class Orders(SQLModel, table=True): + __tablename__ = "orders" + + id: int = Field(primary_key=True) + status: Status = Field(...) + +class Status(str, enum.Enum): + TODO = "todo" + DONE = "done" + +class Tasks(SQLModel, table=True): + __tablename__ = "tasks" + + id: int = Field(primary_key=True) + status: Status = Field(...) diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_shared_snapshot@enum_shared_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_shared_snapshot@enum_shared_Gorm.snap new file mode 100644 index 00000000..3562f672 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_shared_snapshot@enum_shared_Gorm.snap @@ -0,0 +1,21 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type DocStatus string + +const ( + DocStatusDraft DocStatus = "draft" + DocStatusPublished DocStatus = "published" + DocStatusArchived DocStatus = "archived" +) + +type Documents struct { + ID int32 `gorm:"column:id;not null" json:"id"` + Status DocStatus `gorm:"column:status;not null" json:"status"` + ReviewStatus *DocStatus `gorm:"column:review_status" json:"review_status"` +} + +func (Documents) TableName() string { return "documents" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_special_values_snapshot@enum_special_values_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_special_values_snapshot@enum_special_values_Gorm.snap new file mode 100644 index 00000000..50802d97 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_special_values_snapshot@enum_special_values_Gorm.snap @@ -0,0 +1,21 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type EventSeverity string + +const ( + EventSeverityInfo_level EventSeverity = "info-level" + EventSeverityWarningLevel EventSeverity = "warning_level" + EventSeverityERRORLEVEL EventSeverity = "ERROR_LEVEL" + EventSeverity1critical EventSeverity = "1critical" +) + +type Events struct { + ID int32 `gorm:"column:id;not null" json:"id"` + Severity EventSeverity `gorm:"column:severity;not null" json:"severity"` +} + +func (Events) TableName() string { return "events" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_with_default_snapshot@enum_with_default_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_with_default_snapshot@enum_with_default_Gorm.snap new file mode 100644 index 00000000..731b70ee --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_with_default_snapshot@enum_with_default_Gorm.snap @@ -0,0 +1,22 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type TaskStatus string + +const ( + TaskStatusPending TaskStatus = "pending" + TaskStatusInProgress TaskStatus = "in_progress" + TaskStatusCompleted TaskStatus = "completed" +) + +type Tasks struct { + ID int32 `gorm:"column:id;not null" json:"id"` + Status TaskStatus `gorm:"column:status;not null;default:'pending'" json:"status"` + Priority int32 `gorm:"column:priority;not null;default:0" json:"priority"` + IsArchived bool `gorm:"column:is_archived;not null;default:false" json:"is_archived"` +} + +func (Tasks) TableName() string { return "tasks" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__false_boolean_default_snapshot@false_boolean_default_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__false_boolean_default_snapshot@false_boolean_default_Gorm.snap new file mode 100644 index 00000000..0156e396 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__false_boolean_default_snapshot@false_boolean_default_Gorm.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type BoolDefaults struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + IsDeleted bool `gorm:"column:is_deleted;not null;default:false" json:"is_deleted"` +} + +func (BoolDefaults) TableName() string { return "bool_defaults" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_names_collide_after_id_strip_snapshot@fk_names_collide_after_id_strip_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_names_collide_after_id_strip_snapshot@fk_names_collide_after_id_strip_Gorm.snap new file mode 100644 index 00000000..0b68b57a --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_names_collide_after_id_strip_snapshot@fk_names_collide_after_id_strip_Gorm.snap @@ -0,0 +1,24 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type Target struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Alt int32 `gorm:"column:alt;not null;unique" json:"alt"` + SrcsByAID []Src `gorm:"foreignKey:AID" json:"-"` + SrcsByA []Src `gorm:"foreignKey:A;references:Alt" json:"-"` +} + +func (Target) TableName() string { return "target" } + +type Src struct { + Pk int32 `gorm:"column:pk;primaryKey" json:"pk"` + AID *int32 `gorm:"column:a_id" json:"a_id"` + A2 *Target `gorm:"foreignKey:AID" json:"-"` + A *int32 `gorm:"column:a" json:"a"` + ATarget *Target `gorm:"foreignKey:A;references:Alt" json:"-"` +} + +func (Src) TableName() string { return "src" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_with_comment_and_auto_increment_snapshot@fk_with_comment_and_auto_increment_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_with_comment_and_auto_increment_snapshot@fk_with_comment_and_auto_increment_Gorm.snap new file mode 100644 index 00000000..6209f910 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_with_comment_and_auto_increment_snapshot@fk_with_comment_and_auto_increment_Gorm.snap @@ -0,0 +1,14 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type Child struct { + // References parent table + ParentID int32 `gorm:"column:parent_id;primaryKey;autoIncrement" json:"parent_id"` + Parent *Parent `gorm:"foreignKey:ParentID" json:"-"` + Value string `gorm:"column:value;not null;type:text" json:"value"` +} + +func (Child) TableName() string { return "child" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__inline_pk_snapshot@inline_pk_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__inline_pk_snapshot@inline_pk_Gorm.snap new file mode 100644 index 00000000..466c1f1d --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__inline_pk_snapshot@inline_pk_Gorm.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +import ( + "github.com/google/uuid" +) + +type Users struct { + ID uuid.UUID `gorm:"column:id;not null;type:uuid" json:"id"` + Email string `gorm:"column:email;not null;type:text" json:"email"` +} + +func (Users) TableName() string { return "users" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_all_variant_types_snapshot@integer_enum_all_variant_types_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_all_variant_types_snapshot@integer_enum_all_variant_types_Gorm.snap new file mode 100644 index 00000000..b96b5aca --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_all_variant_types_snapshot@integer_enum_all_variant_types_Gorm.snap @@ -0,0 +1,21 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type EdgeState int + +const ( + EdgeStateUnknown EdgeState = -1 + EdgeStateNotStarted EdgeState = 0 + EdgeStateInProgress EdgeState = 10 + EdgeStateHTTP500 EdgeState = 500 +) + +type WorkflowRuns struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + State EdgeState `gorm:"column:state;not null" json:"state"` +} + +func (WorkflowRuns) TableName() string { return "workflow_runs" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_default_snapshot@integer_enum_with_default_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_default_snapshot@integer_enum_with_default_Gorm.snap new file mode 100644 index 00000000..0ff275f6 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_default_snapshot@integer_enum_with_default_Gorm.snap @@ -0,0 +1,19 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type TaskStatus int + +const ( + TaskStatusPending TaskStatus = 0 + TaskStatusCompleted TaskStatus = 100 +) + +type Tasks struct { + ID int32 `gorm:"column:id;not null" json:"id"` + Status TaskStatus `gorm:"column:status;not null;default:1" json:"status"` +} + +func (Tasks) TableName() string { return "tasks" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_variant_default_snapshot@integer_enum_with_variant_default_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_variant_default_snapshot@integer_enum_with_variant_default_Gorm.snap new file mode 100644 index 00000000..690be71a --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_variant_default_snapshot@integer_enum_with_variant_default_Gorm.snap @@ -0,0 +1,19 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type TaskRunStatus int + +const ( + TaskRunStatusPending TaskRunStatus = 0 + TaskRunStatusCompleted TaskRunStatus = 100 +) + +type TaskRuns struct { + ID int32 `gorm:"column:id;not null" json:"id"` + Status TaskRunStatus `gorm:"column:status;not null;default:100" json:"status"` +} + +func (TaskRuns) TableName() string { return "task_runs" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__json_default_snapshot@json_default_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__json_default_snapshot@json_default_Gorm.snap new file mode 100644 index 00000000..6641de16 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__json_default_snapshot@json_default_Gorm.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +import ( + "gorm.io/datatypes" +) + +type Configs struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Data datatypes.JSON `gorm:"column:data;not null;default:{\"hello\": \"world\"}" json:"data"` +} + +func (Configs) TableName() string { return "configs" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__jsonb_custom_type_snapshot@jsonb_custom_type_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__jsonb_custom_type_snapshot@jsonb_custom_type_Gorm.snap new file mode 100644 index 00000000..8a994feb --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__jsonb_custom_type_snapshot@jsonb_custom_type_Gorm.snap @@ -0,0 +1,18 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +import ( + "gorm.io/datatypes" +) + +type JsonStruct struct { + ID int32 `gorm:"column:id;not null" json:"id"` + JsonData datatypes.JSON `gorm:"column:json_data;not null" json:"json_data"` + JsonbData datatypes.JSON `gorm:"column:jsonb_data;not null;type:JSONB" json:"jsonb_data"` + JsonbNullable *datatypes.JSON `gorm:"column:jsonb_nullable;type:jsonb" json:"jsonb_nullable"` +} + +func (JsonStruct) TableName() string { return "json_struct" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__no_description_snapshot@no_description_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__no_description_snapshot@no_description_Gorm.snap new file mode 100644 index 00000000..0c576b1b --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__no_description_snapshot@no_description_Gorm.snap @@ -0,0 +1,11 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type NoDesc struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` +} + +func (NoDesc) TableName() string { return "no_desc" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_names_in_constraints_snapshot@non_identifier_names_in_constraints_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_names_in_constraints_snapshot@non_identifier_names_in_constraints_Gorm.snap new file mode 100644 index 00000000..ee7e3dc9 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_names_in_constraints_snapshot@non_identifier_names_in_constraints_Gorm.snap @@ -0,0 +1,14 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type Membership struct { + X1tenantID int32 `gorm:"column:1tenant_id;primaryKey;uniqueIndex:uq_membership__1tenant_id_user-email" json:"1tenant_id"` + X2userID int32 `gorm:"column:2user_id;primaryKey" json:"2user_id"` + User_email string `gorm:"column:user-email;not null;type:text;uniqueIndex:uq_membership__1tenant_id_user-email" json:"user-email"` + X3created string `gorm:"column:3created;not null;type:text;index:ix_membership__3created" json:"3created"` +} + +func (Membership) TableName() string { return "membership" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_names_snapshot@non_identifier_names_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_names_snapshot@non_identifier_names_Gorm.snap new file mode 100644 index 00000000..b1742e56 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_names_snapshot@non_identifier_names_Gorm.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type X1users struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + X1stPlace *int32 `gorm:"column:1st_place" json:"1st_place"` + User_id *string `gorm:"column:user-id;type:text" json:"user-id"` + X1stOwnerID *int32 `gorm:"column:1st_owner_id" json:"1st_owner_id"` + X1stOwner *X1users `gorm:"foreignKey:X1stOwnerID" json:"-"` +} + +func (X1users) TableName() string { return "1users" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_relation_names_snapshot@non_identifier_relation_names_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_relation_names_snapshot@non_identifier_relation_names_Gorm.snap new file mode 100644 index 00000000..bfa86e0d --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_relation_names_snapshot@non_identifier_relation_names_Gorm.snap @@ -0,0 +1,24 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type X1users struct { + X1id int32 `gorm:"column:1id;primaryKey" json:"1id"` + Email string `gorm:"column:email;not null;type:text" json:"email"` + PostsByX1stOwnerID []Posts `gorm:"foreignKey:X1stOwnerID" json:"-"` + PostsByX2ndOwnerID []Posts `gorm:"foreignKey:X2ndOwnerID" json:"-"` +} + +func (X1users) TableName() string { return "1users" } + +type Posts struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + X1stOwnerID *int32 `gorm:"column:1st_owner_id" json:"1st_owner_id"` + X1stOwner *X1users `gorm:"foreignKey:X1stOwnerID" json:"-"` + X2ndOwnerID *int32 `gorm:"column:2nd_owner_id" json:"2nd_owner_id"` + X2ndOwner *X1users `gorm:"foreignKey:X2ndOwnerID" json:"-"` +} + +func (Posts) TableName() string { return "posts" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_columns_snapshot@nullable_columns_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_columns_snapshot@nullable_columns_Gorm.snap new file mode 100644 index 00000000..bfa63a61 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_columns_snapshot@nullable_columns_Gorm.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type Profiles struct { + ID int32 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + Bio *string `gorm:"column:bio;type:text" json:"bio"` + AvatarUrl *string `gorm:"column:avatar_url;size:500" json:"avatar_url"` +} + +func (Profiles) TableName() string { return "profiles" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_enum_snapshot@nullable_enum_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_enum_snapshot@nullable_enum_Gorm.snap new file mode 100644 index 00000000..4ae98892 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_enum_snapshot@nullable_enum_Gorm.snap @@ -0,0 +1,19 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type StatusType string + +const ( + StatusTypeActive StatusType = "active" + StatusTypeInactive StatusType = "inactive" +) + +type NullableEnum struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Status *StatusType `gorm:"column:status" json:"status"` +} + +func (NullableEnum) TableName() string { return "nullable_enum" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__numeric_default_value_snapshot@numeric_default_value_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__numeric_default_value_snapshot@numeric_default_value_Gorm.snap new file mode 100644 index 00000000..0dd91868 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__numeric_default_value_snapshot@numeric_default_value_Gorm.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +import ( + "github.com/shopspring/decimal" +) + +type Products struct { + ID int32 `gorm:"column:id;not null" json:"id"` + Price decimal.Decimal `gorm:"column:price;not null;type:numeric(10,2);default:0" json:"price"` +} + +func (Products) TableName() string { return "products" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__pk_and_fk_together_snapshot@pk_and_fk_together_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__pk_and_fk_together_snapshot@pk_and_fk_together_Gorm.snap new file mode 100644 index 00000000..49a064a3 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__pk_and_fk_together_snapshot@pk_and_fk_together_Gorm.snap @@ -0,0 +1,24 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +import ( + "time" + + "github.com/google/uuid" +) + +type ArticleUser struct { + ArticleID uuid.UUID `gorm:"column:article_id;primaryKey;type:uuid;index:ix_article_user__article_id" json:"article_id"` + Article *Article `gorm:"foreignKey:ArticleID;constraint:OnDelete:CASCADE" json:"-"` + UserID uuid.UUID `gorm:"column:user_id;primaryKey;type:uuid;index:ix_article_user__user_id" json:"user_id"` + User *User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE" json:"-"` + AuthorOrder int32 `gorm:"column:author_order;not null;default:1" json:"author_order"` + Role string `gorm:"column:role;not null;size:20;default:'contributor'" json:"role"` + IsLead bool `gorm:"column:is_lead;not null;default:false" json:"is_lead"` + CreatedAt time.Time `gorm:"column:created_at;not null" json:"created_at"` +} + +func (ArticleUser) TableName() string { return "article_user" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_Drizzle_pg.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_Drizzle_pg.snap new file mode 100644 index 00000000..4276eac6 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_Drizzle_pg.snap @@ -0,0 +1,38 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +export const users = pgTable("users", { + id: integer("id").primaryKey(), +}); + +export const usersRelations = relations(users, ({ one, many }) => ({ + posts: many(posts), + comments: many(comments), +})); + +export const posts = pgTable("posts", { + id: integer("id").primaryKey(), + userId: integer("user_id").notNull(), +}, (t) => [ + foreignKey({ columns: [t.userId], foreignColumns: [users.id], name: "fk_posts__user_id" }).onDelete("cascade").onUpdate("restrict"), +]); + +export const postsRelations = relations(posts, ({ one, many }) => ({ + user: one(users, { fields: [posts.userId], references: [users.id] }), + comments: many(comments), +})); + +export const comments = pgTable("comments", { + id: integer("id").primaryKey(), + postId: integer("post_id"), + authorId: integer("author_id").notNull().default(1), +}, (t) => [ + foreignKey({ columns: [t.postId], foreignColumns: [posts.id], name: "fk_comments__post_id" }).onDelete("set default").onUpdate("no action"), + foreignKey({ columns: [t.authorId], foreignColumns: [users.id], name: "fk_comments__author_id" }).onDelete("set default").onUpdate("cascade"), +]); + +export const commentsRelations = relations(comments, ({ one, many }) => ({ + post: one(posts, { fields: [comments.postId], references: [posts.id] }), + author: one(users, { fields: [comments.authorId], references: [users.id] }), +})); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_Gorm.snap new file mode 100644 index 00000000..91afa408 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_Gorm.snap @@ -0,0 +1,32 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type Users struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Posts []Posts `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE,OnUpdate:RESTRICT" json:"-"` + Comments []Comments `gorm:"foreignKey:AuthorID;constraint:OnDelete:SET DEFAULT,OnUpdate:CASCADE" json:"-"` +} + +func (Users) TableName() string { return "users" } + +type Posts struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + UserID int32 `gorm:"column:user_id;not null" json:"user_id"` + User *Users `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE,OnUpdate:RESTRICT" json:"-"` + Comments []Comments `gorm:"foreignKey:PostID;constraint:OnDelete:SET DEFAULT,OnUpdate:NO ACTION" json:"-"` +} + +func (Posts) TableName() string { return "posts" } + +type Comments struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + PostID *int32 `gorm:"column:post_id" json:"post_id"` + Post *Posts `gorm:"foreignKey:PostID;constraint:OnDelete:SET DEFAULT,OnUpdate:NO ACTION" json:"-"` + AuthorID int32 `gorm:"column:author_id;not null;default:1" json:"author_id"` + Author *Users `gorm:"foreignKey:AuthorID;constraint:OnDelete:SET DEFAULT,OnUpdate:CASCADE" json:"-"` +} + +func (Comments) TableName() string { return "comments" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_Jpa.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_Jpa.snap new file mode 100644 index 00000000..815a986d --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_Jpa.snap @@ -0,0 +1,57 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +import jakarta.persistence.*; + +@Entity +@Table(name = "users") +public class Users { + + @Id + @Column(name = "id") + private Integer id; + + protected Users() { + } +} + +import jakarta.persistence.*; + +@Entity +@Table(name = "posts") +public class Posts { + + @Id + @Column(name = "id") + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private Users user; + + protected Posts() { + } +} + +import jakarta.persistence.*; + +@Entity +@Table(name = "comments") +public class Comments { + + @Id + @Column(name = "id") + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id") + private Posts post; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "author_id", nullable = false) + private Users author; + + protected Comments() { + } +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_Prisma.snap new file mode 100644 index 00000000..435d31d2 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_Prisma.snap @@ -0,0 +1,30 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model Users { + id Int @id + posts Posts[] + comments Comments[] + + @@map("users") +} + +model Posts { + id Int @id + user_id Int + user Users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: Restrict) + comments Comments[] + + @@map("posts") +} + +model Comments { + id Int @id + post_id Int? + post Posts? @relation(fields: [post_id], references: [id], onDelete: SetDefault, onUpdate: NoAction) + author_id Int @default(1) + author Users @relation(fields: [author_id], references: [id], onDelete: SetDefault, onUpdate: Cascade) + + @@map("comments") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_SeaOrm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_SeaOrm.snap new file mode 100644 index 00000000..61063a99 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_SeaOrm.snap @@ -0,0 +1,60 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "users")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + #[sea_orm(has_many)] + pub posts: HasMany, + #[sea_orm(has_many)] + pub comments: HasMany, +} + +vespera::schema_type!(Schema from Model, name = "UsersSchema"); +impl ActiveModelBehavior for ActiveModel {} + + +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "posts")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub user_id: i32, + #[sea_orm(belongs_to, from = "user_id", to = "id")] + pub user: HasOne, + #[sea_orm(has_many)] + pub comments: HasMany, +} + +vespera::schema_type!(Schema from Model, name = "PostsSchema"); +impl ActiveModelBehavior for ActiveModel {} + + +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "comments")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub post_id: Option, + #[sea_orm(default_value = 1)] + pub author_id: i32, + #[sea_orm(belongs_to, from = "post_id", to = "id")] + pub post: HasOne, + #[sea_orm(belongs_to, from = "author_id", to = "id")] + pub author: HasOne, +} + +vespera::schema_type!(Schema from Model, name = "CommentsSchema"); +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_SqlAlchemy.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_SqlAlchemy.snap new file mode 100644 index 00000000..0bc9b096 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_SqlAlchemy.snap @@ -0,0 +1,29 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from typing import Optional + +from sqlalchemy import ForeignKey, Integer +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Users(DeclarativeBase): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + +class Posts(DeclarativeBase): + __tablename__ = "posts" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False) + +class Comments(DeclarativeBase): + __tablename__ = "comments" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + post_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("posts.id"), nullable=True) + author_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False, server_default="1") diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_SqlModel.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_SqlModel.snap new file mode 100644 index 00000000..52c71bbd --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_SqlModel.snap @@ -0,0 +1,28 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from typing import Optional + +from sqlmodel import Field, SQLModel + + +class Users(SQLModel, table=True): + __tablename__ = "users" + + id: int = Field(primary_key=True) + +class Posts(SQLModel, table=True): + __tablename__ = "posts" + + id: int = Field(primary_key=True) + user_id: int = Field(foreign_key="users.id") + +class Comments(SQLModel, table=True): + __tablename__ = "comments" + + id: int = Field(primary_key=True) + post_id: Optional[int] = Field(default=None, foreign_key="posts.id") + author_id: int = Field(default=1, foreign_key="users.id") diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_Drizzle_pg.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_Drizzle_pg.snap new file mode 100644 index 00000000..dc566826 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_Drizzle_pg.snap @@ -0,0 +1,64 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +export const orderRegions = pgTable("order_regions", { + orderId: integer("order_id").notNull(), + regionId: integer("region_id").notNull(), +}, (t) => [ + primaryKey({ name: "order_regions_pkey", columns: [t.orderId, t.regionId] }), +]); + +export const orderRegionsRelations = relations(orderRegions, ({ one, many }) => ({ + orderItems: many(orderItems), +})); + +export const orderItems = pgTable("order_items", { + id: integer("id").primaryKey(), + orderId: integer("order_id").notNull(), + regionId: integer("region_id").notNull(), + orderRegions: text("order_regions").notNull(), + orderRegions2: text("order_regions2").notNull(), +}, (t) => [ + foreignKey({ columns: [t.orderId, t.regionId], foreignColumns: [orderRegions.orderId, orderRegions.regionId], name: "fk_order_items__order_id_region_id" }).onDelete("cascade").onUpdate("restrict"), +]); + +export const orderItemsRelations = relations(orderItems, ({ one, many }) => ({ + orderRegion: one(orderRegions, { fields: [orderItems.orderId, orderItems.regionId], references: [orderRegions.orderId, orderRegions.regionId] }), +})); + +export const users = pgTable("users", { + id: integer("id").primaryKey(), + posts: text("posts").notNull(), +}); + +export const usersRelations = relations(users, ({ one, many }) => ({ + posts_rel: many(posts), +})); + +export const posts = pgTable("posts", { + id: integer("id").primaryKey(), + userId: integer("user_id").notNull(), + tableNameId: integer("table_name_id").notNull(), +}, (t) => [ + foreignKey({ columns: [t.userId], foreignColumns: [users.id], name: "fk_posts__user_id" }), + foreignKey({ columns: [t.tableNameId], foreignColumns: [categories.id], name: "fk_posts__table_name_id" }), +]); + +export const postsRelations = relations(posts, ({ one, many }) => ({ + user: one(users, { fields: [posts.userId], references: [users.id] }), + tableName: one(categories, { fields: [posts.tableNameId], references: [categories.id] }), +})); + +export const categories = pgTable("categories", { + id: integer("id").primaryKey(), + parentId: integer("parent_id"), +}, (t) => [ + foreignKey({ columns: [t.parentId], foreignColumns: [t.id], name: "fk_categories__parent_id" }), +]); + +export const categoriesRelations = relations(categories, ({ one, many }) => ({ + parent: one(categories, { fields: [categories.parentId], references: [categories.id], relationName: "CategoriesParent" }), + posts: many(posts), + parentCategories: many(categories, { relationName: "CategoriesParent" }), +})); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_Gorm.snap new file mode 100644 index 00000000..47c79db0 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_Gorm.snap @@ -0,0 +1,52 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type OrderRegions struct { + OrderID int32 `gorm:"column:order_id;primaryKey" json:"order_id"` + RegionID int32 `gorm:"column:region_id;primaryKey" json:"region_id"` + OrderItems []OrderItems `gorm:"foreignKey:OrderID,RegionID;references:OrderID,RegionID;constraint:OnDelete:CASCADE,OnUpdate:RESTRICT" json:"-"` +} + +func (OrderRegions) TableName() string { return "order_regions" } + +type OrderItems struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + OrderID int32 `gorm:"column:order_id;not null" json:"order_id"` + RegionID int32 `gorm:"column:region_id;not null" json:"region_id"` + OrderRegions string `gorm:"column:order_regions;not null;type:text" json:"order_regions"` + OrderRegions2 string `gorm:"column:order_regions2;not null;type:text" json:"order_regions2"` + OrderRegions3 *OrderRegions `gorm:"foreignKey:OrderID,RegionID;references:OrderID,RegionID;constraint:OnDelete:CASCADE,OnUpdate:RESTRICT" json:"-"` +} + +func (OrderItems) TableName() string { return "order_items" } + +type Users struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Posts string `gorm:"column:posts;not null;type:text" json:"posts"` + Posts2 []Posts `gorm:"foreignKey:UserID" json:"-"` +} + +func (Users) TableName() string { return "users" } + +type Posts struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + UserID int32 `gorm:"column:user_id;not null" json:"user_id"` + User *Users `gorm:"foreignKey:UserID" json:"-"` + TableNameID int32 `gorm:"column:table_name_id;not null" json:"table_name_id"` + TableName2 *Categories `gorm:"foreignKey:TableNameID" json:"-"` +} + +func (Posts) TableName() string { return "posts" } + +type Categories struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + ParentID *int32 `gorm:"column:parent_id" json:"parent_id"` + Parent *Categories `gorm:"foreignKey:ParentID" json:"-"` + Posts []Posts `gorm:"foreignKey:TableNameID" json:"-"` + Children []Categories `gorm:"foreignKey:ParentID" json:"-"` +} + +func (Categories) TableName() string { return "categories" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_Jpa.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_Jpa.snap new file mode 100644 index 00000000..44ce8612 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_Jpa.snap @@ -0,0 +1,104 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +import jakarta.persistence.*; + +@Entity +@Table(name = "order_regions") +public class OrderRegions { + + @Id + @Column(name = "order_id") + private Integer orderId; + + @Id + @Column(name = "region_id") + private Integer regionId; + + protected OrderRegions() { + } +} + +import jakarta.persistence.*; + +@Entity +@Table(name = "order_items") +public class OrderItems { + + @Id + @Column(name = "id") + private Integer id; + + @Column(name = "order_id", nullable = false) + private Integer orderId; + + @Column(name = "region_id", nullable = false) + private Integer regionId; + + @Column(name = "order_regions", nullable = false, columnDefinition = "TEXT") + private String orderRegions; + + @Column(name = "order_regions2", nullable = false, columnDefinition = "TEXT") + private String orderRegions2; + + protected OrderItems() { + } +} + +import jakarta.persistence.*; + +@Entity +@Table(name = "users") +public class Users { + + @Id + @Column(name = "id") + private Integer id; + + @Column(name = "posts", nullable = false, columnDefinition = "TEXT") + private String posts; + + protected Users() { + } +} + +import jakarta.persistence.*; + +@Entity +@Table(name = "posts") +public class Posts { + + @Id + @Column(name = "id") + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private Users user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "table_name_id", nullable = false) + private Categories tableName; + + protected Posts() { + } +} + +import jakarta.persistence.*; + +@Entity +@Table(name = "categories") +public class Categories { + + @Id + @Column(name = "id") + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_id") + private Categories parent; + + protected Categories() { + } +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_Prisma.snap new file mode 100644 index 00000000..3b64d2f8 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_Prisma.snap @@ -0,0 +1,51 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model OrderRegions { + order_id Int + region_id Int + order_items OrderItems[] + + @@id([order_id, region_id]) + @@map("order_regions") +} + +model OrderItems { + id Int @id + order_id Int + region_id Int + order_regions String + order_regions2 String + order_regions_rel OrderRegions @relation(fields: [order_id, region_id], references: [order_id, region_id], onDelete: Cascade, onUpdate: Restrict) + + @@map("order_items") +} + +model Users { + id Int @id + posts String + posts_rel Posts[] + + @@map("users") +} + +model Posts { + id Int @id + user_id Int + user Users @relation(fields: [user_id], references: [id], onDelete: NoAction, onUpdate: NoAction) + table_name_id Int + table_name Categories @relation(fields: [table_name_id], references: [id], onDelete: NoAction, onUpdate: NoAction) + + @@map("posts") +} + +model Categories { + id Int @id + parent_id Int? + parent Categories? @relation("CategoriesParent", fields: [parent_id], references: [id], onDelete: NoAction, onUpdate: NoAction) + posts Posts[] + parent_categories Categories[] @relation("CategoriesParent") + + @@map("categories") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_SeaOrm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_SeaOrm.snap new file mode 100644 index 00000000..5d1383ee --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_SeaOrm.snap @@ -0,0 +1,96 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "order_regions")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub order_id: i32, + #[sea_orm(primary_key, auto_increment = false)] + pub region_id: i32, + #[sea_orm(has_many)] + pub order_items: HasMany, +} + +vespera::schema_type!(Schema from Model, name = "OrderRegionsSchema"); +impl ActiveModelBehavior for ActiveModel {} + + +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "order_items")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub order_id: i32, + pub region_id: i32, + pub order_regions: String, + pub order_regions2: String, + #[sea_orm(belongs_to, from = "(order_id, region_id)", to = "(order_id, region_id)")] + pub order_regions_1: HasOne, +} + +vespera::schema_type!(Schema from Model, name = "OrderItemsSchema"); +impl ActiveModelBehavior for ActiveModel {} + + +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "users")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub posts: String, + #[sea_orm(has_many)] + pub posts_1: HasMany, +} + +vespera::schema_type!(Schema from Model, name = "UsersSchema"); +impl ActiveModelBehavior for ActiveModel {} + + +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "posts")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub user_id: i32, + pub table_name_id: i32, + #[sea_orm(belongs_to, from = "user_id", to = "id")] + pub user: HasOne, + #[sea_orm(belongs_to, from = "table_name_id", to = "id")] + pub table_name: HasOne, +} + +vespera::schema_type!(Schema from Model, name = "PostsSchema"); +impl ActiveModelBehavior for ActiveModel {} + + +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "categories")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub parent_id: Option, + #[sea_orm(belongs_to, from = "parent_id", to = "id")] + pub parent: HasOne, + #[sea_orm(has_many)] + pub posts: HasMany, +} + +vespera::schema_type!(Schema from Model, name = "CategoriesSchema"); +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_SqlAlchemy.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_SqlAlchemy.snap new file mode 100644 index 00000000..fe9b9bfb --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_SqlAlchemy.snap @@ -0,0 +1,49 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from typing import Optional + +from sqlalchemy import ForeignKey, ForeignKeyConstraint, Integer, Text +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class OrderRegions(DeclarativeBase): + __tablename__ = "order_regions" + + order_id: Mapped[int] = mapped_column(Integer, primary_key=True) + region_id: Mapped[int] = mapped_column(Integer, primary_key=True) + +class OrderItems(DeclarativeBase): + __tablename__ = "order_items" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + order_id: Mapped[int] = mapped_column(Integer, nullable=False) + region_id: Mapped[int] = mapped_column(Integer, nullable=False) + order_regions: Mapped[str] = mapped_column(Text, nullable=False) + order_regions2: Mapped[str] = mapped_column(Text, nullable=False) + + __table_args__ = ( + ForeignKeyConstraint(["order_id", "region_id"], ["order_regions.order_id", "order_regions.region_id"]), + ) + +class Users(DeclarativeBase): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + posts: Mapped[str] = mapped_column(Text, nullable=False) + +class Posts(DeclarativeBase): + __tablename__ = "posts" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False) + table_name_id: Mapped[int] = mapped_column(Integer, ForeignKey("categories.id"), nullable=False) + +class Categories(DeclarativeBase): + __tablename__ = "categories" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + parent_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("categories.id"), nullable=True) diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_SqlModel.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_SqlModel.snap new file mode 100644 index 00000000..7cfc6aca --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_SqlModel.snap @@ -0,0 +1,49 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from typing import Optional + +from sqlmodel import Field, SQLModel +from sqlalchemy import ForeignKeyConstraint + + +class OrderRegions(SQLModel, table=True): + __tablename__ = "order_regions" + + order_id: int = Field(primary_key=True) + region_id: int = Field(primary_key=True) + +class OrderItems(SQLModel, table=True): + __tablename__ = "order_items" + + id: int = Field(primary_key=True) + order_id: int = Field(...) + region_id: int = Field(...) + order_regions: str = Field(...) + order_regions2: str = Field(...) + + __table_args__ = ( + ForeignKeyConstraint(["order_id", "region_id"], ["order_regions.order_id", "order_regions.region_id"]), + ) + +class Users(SQLModel, table=True): + __tablename__ = "users" + + id: int = Field(primary_key=True) + posts: str = Field(...) + +class Posts(SQLModel, table=True): + __tablename__ = "posts" + + id: int = Field(primary_key=True) + user_id: int = Field(foreign_key="users.id") + table_name_id: int = Field(foreign_key="categories.id") + +class Categories(SQLModel, table=True): + __tablename__ = "categories" + + id: int = Field(primary_key=True) + parent_id: Optional[int] = Field(default=None, foreign_key="categories.id") diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_name_taken_by_column_snapshot@relation_name_taken_by_column_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_name_taken_by_column_snapshot@relation_name_taken_by_column_Gorm.snap new file mode 100644 index 00000000..142def26 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_name_taken_by_column_snapshot@relation_name_taken_by_column_Gorm.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type Users struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Items []Items `gorm:"foreignKey:Owner" json:"-"` +} + +func (Users) TableName() string { return "users" } + +type Items struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Owner *int32 `gorm:"column:owner" json:"owner"` + OwnerUsers *Users `gorm:"foreignKey:Owner" json:"-"` +} + +func (Items) TableName() string { return "items" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@composite_and_single_fk_same_target_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@composite_and_single_fk_same_target_Gorm.snap new file mode 100644 index 00000000..91fb8d13 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@composite_and_single_fk_same_target_Gorm.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type Src struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + AID int32 `gorm:"column:a_id;not null" json:"a_id"` + BID int32 `gorm:"column:b_id;not null" json:"b_id"` + Solo int32 `gorm:"column:solo;not null" json:"solo"` + SoloTarget *Target `gorm:"foreignKey:Solo;references:U" json:"-"` + Target *Target `gorm:"foreignKey:AID,BID;references:A,B" json:"-"` +} + +func (Src) TableName() string { return "src" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@composite_fk_parent_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@composite_fk_parent_Gorm.snap new file mode 100644 index 00000000..c18a70b3 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@composite_fk_parent_Gorm.snap @@ -0,0 +1,14 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type Parent struct { + ID1 int32 `gorm:"column:id1;primaryKey" json:"id1"` + ID2 int32 `gorm:"column:id2;primaryKey" json:"id2"` + ChildOne *ChildOne `gorm:"foreignKey:ParentID1,ParentID2;references:ID1,ID2" json:"-"` + ChildManies []ChildMany `gorm:"foreignKey:ParentID1,ParentID2;references:ID1,ID2" json:"-"` +} + +func (Parent) TableName() string { return "parent" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@dual_reverse_relations_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@dual_reverse_relations_Gorm.snap new file mode 100644 index 00000000..63c212b6 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@dual_reverse_relations_Gorm.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type Dual struct { + Username string `gorm:"column:username;primaryKey;type:text" json:"username"` + DualRelsByUsername []DualRel `gorm:"foreignKey:Username" json:"-"` + DualRelsByCheckerUsername []DualRel `gorm:"foreignKey:CheckerUsername" json:"-"` +} + +func (Dual) TableName() string { return "dual" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_article_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_article_Gorm.snap new file mode 100644 index 00000000..0d37a920 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_article_Gorm.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type Article struct { + ID int64 `gorm:"column:id;primaryKey" json:"id"` + ArticleUsers []ArticleUser `gorm:"foreignKey:ArticleID" json:"-"` +} + +func (Article) TableName() string { return "article" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_missing_target_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_missing_target_Gorm.snap new file mode 100644 index 00000000..0d37a920 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_missing_target_Gorm.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type Article struct { + ID int64 `gorm:"column:id;primaryKey" json:"id"` + ArticleUsers []ArticleUser `gorm:"foreignKey:ArticleID" json:"-"` +} + +func (Article) TableName() string { return "article" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_multiple_junctions_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_multiple_junctions_Gorm.snap new file mode 100644 index 00000000..e738b272 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_multiple_junctions_Gorm.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +import ( + "github.com/google/uuid" +) + +type User struct { + ID uuid.UUID `gorm:"column:id;primaryKey;type:uuid" json:"id"` + UserMediaRoles []UserMediaRole `gorm:"foreignKey:UserID" json:"-"` + UserMediaFavorites []UserMediaFavorite `gorm:"foreignKey:UserID" json:"-"` +} + +func (User) TableName() string { return "user" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_user_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_user_Gorm.snap new file mode 100644 index 00000000..5d12cead --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_user_Gorm.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +import ( + "github.com/google/uuid" +) + +type User struct { + ID uuid.UUID `gorm:"column:id;primaryKey;type:uuid" json:"id"` + ArticleUsers []ArticleUser `gorm:"foreignKey:UserID" json:"-"` +} + +func (User) TableName() string { return "user" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_fk_same_table_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_fk_same_table_Gorm.snap new file mode 100644 index 00000000..153d38ef --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_fk_same_table_Gorm.snap @@ -0,0 +1,19 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +import ( + "github.com/google/uuid" +) + +type Post struct { + ID uuid.UUID `gorm:"column:id;primaryKey;type:uuid" json:"id"` + CreatorUserID uuid.UUID `gorm:"column:creator_user_id;not null;type:uuid" json:"creator_user_id"` + CreatorUser *User `gorm:"foreignKey:CreatorUserID" json:"-"` + UsedByUserID uuid.UUID `gorm:"column:used_by_user_id;not null;type:uuid" json:"used_by_user_id"` + UsedByUser *User `gorm:"foreignKey:UsedByUserID" json:"-"` +} + +func (Post) TableName() string { return "post" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_has_one_relations_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_has_one_relations_Gorm.snap new file mode 100644 index 00000000..f1d5bdb4 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_has_one_relations_Gorm.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +import ( + "github.com/google/uuid" +) + +type User struct { + ID uuid.UUID `gorm:"column:id;primaryKey;type:uuid" json:"id"` + SettingsByCreatedByUserID *Settings `gorm:"foreignKey:CreatedByUserID" json:"-"` + SettingsByUpdatedByUserID *Settings `gorm:"foreignKey:UpdatedByUserID" json:"-"` +} + +func (User) TableName() string { return "user" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_reverse_relations_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_reverse_relations_Gorm.snap new file mode 100644 index 00000000..17e350d1 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_reverse_relations_Gorm.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +import ( + "github.com/google/uuid" +) + +type User struct { + ID uuid.UUID `gorm:"column:id;primaryKey;type:uuid" json:"id"` + ProfilesByPreferredUserID []Profile `gorm:"foreignKey:PreferredUserID" json:"-"` + ProfilesByBackupUserID []Profile `gorm:"foreignKey:BackupUserID" json:"-"` +} + +func (User) TableName() string { return "user" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_fk_not_in_pk_another_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_fk_not_in_pk_another_Gorm.snap new file mode 100644 index 00000000..cfee814f --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_fk_not_in_pk_another_Gorm.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type Another struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + NotJunctions []NotJunction `gorm:"foreignKey:AnotherID" json:"-"` +} + +func (Another) TableName() string { return "another" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_fk_not_in_pk_other_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_fk_not_in_pk_other_Gorm.snap new file mode 100644 index 00000000..06f4da02 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_fk_not_in_pk_other_Gorm.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type Other struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + NotJunctions []NotJunction `gorm:"foreignKey:OtherID" json:"-"` +} + +func (Other) TableName() string { return "other" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_single_pk_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_single_pk_Gorm.snap new file mode 100644 index 00000000..6a869972 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_single_pk_Gorm.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type Other struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Regulars []Regular `gorm:"foreignKey:OtherID" json:"-"` +} + +func (Other) TableName() string { return "other" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_Drizzle_pg.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_Drizzle_pg.snap new file mode 100644 index 00000000..bae07af0 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_Drizzle_pg.snap @@ -0,0 +1,11 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +export const user = pgTable("user", { + id: uuid("id").primaryKey(), +}); + +export const userRelations = relations(user, ({ one, many }) => ({ + profile: one(profile), +})); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_Gorm.snap new file mode 100644 index 00000000..f0640e09 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_Gorm.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +import ( + "github.com/google/uuid" +) + +type User struct { + ID uuid.UUID `gorm:"column:id;primaryKey;type:uuid" json:"id"` + Profile *Profile `gorm:"foreignKey:UserID" json:"-"` +} + +func (User) TableName() string { return "user" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_Jpa.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_Jpa.snap new file mode 100644 index 00000000..247cd774 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_Jpa.snap @@ -0,0 +1,18 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +import jakarta.persistence.*; +import java.util.UUID; + +@Entity +@Table(name = "user") +public class User { + + @Id + @Column(name = "id") + private UUID id; + + protected User() { + } +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_Prisma.snap new file mode 100644 index 00000000..d19632cb --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_Prisma.snap @@ -0,0 +1,10 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model User { + id String @id + profile Profile? + + @@map("user") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_SeaOrm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_SeaOrm.snap new file mode 100644 index 00000000..dd497c41 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_SeaOrm.snap @@ -0,0 +1,18 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "user")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: Uuid, + #[sea_orm(has_one)] + pub profile: HasOne, +} + +vespera::schema_type!(Schema from Model, name = "UserSchema"); +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_SqlAlchemy.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_SqlAlchemy.snap new file mode 100644 index 00000000..98172029 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_SqlAlchemy.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import Uuid +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class User(DeclarativeBase): + __tablename__ = "user" + + id: Mapped[UUID] = mapped_column(Uuid, primary_key=True) diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_SqlModel.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_SqlModel.snap new file mode 100644 index 00000000..b25f7d83 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_SqlModel.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from uuid import UUID + +from sqlmodel import Field, SQLModel + + +class User(SQLModel, table=True): + __tablename__ = "user" + + id: UUID = Field(primary_key=True) diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_Drizzle_pg.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_Drizzle_pg.snap new file mode 100644 index 00000000..5141a50e --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_Drizzle_pg.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +export const profile = pgTable("profile", { + id: uuid("id").primaryKey(), + userId: uuid("user_id").notNull(), +}, (t) => [ + foreignKey({ columns: [t.userId], foreignColumns: [user.id], name: "fk_profile__user_id" }), + uniqueIndex("uq_profile__user_id").on(t.userId), +]); + +export const profileRelations = relations(profile, ({ one, many }) => ({ + user: one(user, { fields: [profile.userId], references: [user.id] }), +})); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_Gorm.snap new file mode 100644 index 00000000..9c691fab --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_Gorm.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +import ( + "github.com/google/uuid" +) + +type Profile struct { + ID uuid.UUID `gorm:"column:id;primaryKey;type:uuid" json:"id"` + UserID uuid.UUID `gorm:"column:user_id;not null;unique;type:uuid" json:"user_id"` + User *User `gorm:"foreignKey:UserID" json:"-"` +} + +func (Profile) TableName() string { return "profile" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_Jpa.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_Jpa.snap new file mode 100644 index 00000000..3430660c --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_Jpa.snap @@ -0,0 +1,22 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +import jakarta.persistence.*; +import java.util.UUID; + +@Entity +@Table(name = "profile") +public class Profile { + + @Id + @Column(name = "id") + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + protected Profile() { + } +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_Prisma.snap new file mode 100644 index 00000000..d489ce7b --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_Prisma.snap @@ -0,0 +1,11 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model Profile { + id String @id + user_id String @unique(map: "uq_profile__user_id") + user User @relation(fields: [user_id], references: [id], onDelete: NoAction, onUpdate: NoAction) + + @@map("profile") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_SeaOrm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_SeaOrm.snap new file mode 100644 index 00000000..d708c0f0 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_SeaOrm.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "profile")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: Uuid, + #[sea_orm(unique)] + pub user_id: Uuid, + #[sea_orm(belongs_to, from = "user_id", to = "id")] + pub user: HasOne, +} + +vespera::schema_type!(Schema from Model, name = "ProfileSchema"); +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_SqlAlchemy.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_SqlAlchemy.snap new file mode 100644 index 00000000..195aa999 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_SqlAlchemy.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import ForeignKey, Uuid +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Profile(DeclarativeBase): + __tablename__ = "profile" + + id: Mapped[UUID] = mapped_column(Uuid, primary_key=True) + user_id: Mapped[UUID] = mapped_column(Uuid, ForeignKey("user.id"), nullable=False, unique=True) diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_SqlModel.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_SqlModel.snap new file mode 100644 index 00000000..656f2c4d --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_SqlModel.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from uuid import UUID + +from sqlmodel import Field, SQLModel + + +class Profile(SQLModel, table=True): + __tablename__ = "profile" + + id: UUID = Field(primary_key=True) + user_id: UUID = Field(foreign_key="user.id", unique=True) diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@triple_reverse_relations_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@triple_reverse_relations_Gorm.snap new file mode 100644 index 00000000..65f25b10 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@triple_reverse_relations_Gorm.snap @@ -0,0 +1,14 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type Dual struct { + Username string `gorm:"column:username;primaryKey;type:text" json:"username"` + TripleRelsByUsername []TripleRel `gorm:"foreignKey:Username" json:"-"` + TripleRelsByCheckerUsername []TripleRel `gorm:"foreignKey:CheckerUsername" json:"-"` + TripleRelsByOtherUsername []TripleRel `gorm:"foreignKey:OtherUsername" json:"-"` +} + +func (Dual) TableName() string { return "dual" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@username_fk_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@username_fk_Gorm.snap new file mode 100644 index 00000000..6648185d --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@username_fk_Gorm.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +import ( + "github.com/google/uuid" +) + +type Session struct { + ID uuid.UUID `gorm:"column:id;primaryKey;type:uuid" json:"id"` + Username string `gorm:"column:username;not null;type:text" json:"username"` + UsernameUser *User `gorm:"foreignKey:Username" json:"-"` +} + +func (Session) TableName() string { return "session" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reserved_word_identifiers_snapshot@reserved_word_identifiers_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reserved_word_identifiers_snapshot@reserved_word_identifiers_Gorm.snap new file mode 100644 index 00000000..1e360b7d --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reserved_word_identifiers_snapshot@reserved_word_identifiers_Gorm.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type Order struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + User string `gorm:"column:user;not null;type:text" json:"user"` + Select int32 `gorm:"column:select;not null" json:"select"` +} + +func (Order) TableName() string { return "order" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__self_referencing_fk_snapshot@self_referencing_fk_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__self_referencing_fk_snapshot@self_referencing_fk_Gorm.snap new file mode 100644 index 00000000..2d158abd --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__self_referencing_fk_snapshot@self_referencing_fk_Gorm.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type Employees struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + ManagerID *int32 `gorm:"column:manager_id" json:"manager_id"` + Manager *Employees `gorm:"foreignKey:ManagerID;constraint:OnDelete:SET NULL" json:"-"` +} + +func (Employees) TableName() string { return "employees" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_Drizzle_pg.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_Drizzle_pg.snap new file mode 100644 index 00000000..9daa70dc --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_Drizzle_pg.snap @@ -0,0 +1,9 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +export const notes = pgTable("notes", { + id: integer("id").primaryKey(), + note: text("note").notNull().default("a;b"), + body: text("body").notNull(), +}); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_Gorm.snap new file mode 100644 index 00000000..c694a7c7 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_Gorm.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type Notes struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Note string `gorm:"column:note;not null;type:text" json:"note"` + Body string `gorm:"column:body;not null;type:text" json:"body"` +} + +func (Notes) TableName() string { return "notes" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_Jpa.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_Jpa.snap new file mode 100644 index 00000000..51e05134 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_Jpa.snap @@ -0,0 +1,23 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +import jakarta.persistence.*; + +@Entity +@Table(name = "notes") +public class Notes { + + @Id + @Column(name = "id") + private Integer id; + + @Column(name = "note", nullable = false, columnDefinition = "TEXT") + private String note = "a;b"; + + @Column(name = "body", nullable = false, columnDefinition = "TEXT") + private String body; + + protected Notes() { + } +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_Prisma.snap new file mode 100644 index 00000000..28eacb9a --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_Prisma.snap @@ -0,0 +1,11 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model Notes { + id Int @id + note String @default("a;b") + body String + + @@map("notes") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_SeaOrm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_SeaOrm.snap new file mode 100644 index 00000000..13b09613 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_SeaOrm.snap @@ -0,0 +1,19 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "notes")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + #[sea_orm(default_value = "a;b")] + pub note: String, + pub body: String, +} + +vespera::schema_type!(Schema from Model, name = "NotesSchema"); +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_SqlAlchemy.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_SqlAlchemy.snap new file mode 100644 index 00000000..407ef820 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_SqlAlchemy.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + + +from sqlalchemy import Integer, Text +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Notes(DeclarativeBase): + __tablename__ = "notes" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + note: Mapped[str] = mapped_column(Text, nullable=False, server_default='a;b') + body: Mapped[str] = mapped_column(Text, nullable=False) diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_SqlModel.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_SqlModel.snap new file mode 100644 index 00000000..ef542948 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_SqlModel.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + + +from sqlmodel import Field, SQLModel + + +class Notes(SQLModel, table=True): + __tablename__ = "notes" + + id: int = Field(primary_key=True) + note: str = Field(default="a;b") + body: str = Field(...) diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_default_and_true_boolean_snapshot@server_default_and_true_boolean_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_default_and_true_boolean_snapshot@server_default_and_true_boolean_Gorm.snap new file mode 100644 index 00000000..202bd5ab --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_default_and_true_boolean_snapshot@server_default_and_true_boolean_Gorm.snap @@ -0,0 +1,19 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +import ( + "time" +) + +type Logs struct { + ID int32 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + Active bool `gorm:"column:active;not null;default:true" json:"active"` + CreatedAt time.Time `gorm:"column:created_at;not null" json:"created_at"` + Score float32 `gorm:"column:score;not null;default:1.5" json:"score"` + Tag string `gorm:"column:tag;not null;type:text;default:UNKNOWN_EXPR" json:"tag"` +} + +func (Logs) TableName() string { return "logs" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_defaults_snapshot@server_defaults_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_defaults_snapshot@server_defaults_Gorm.snap new file mode 100644 index 00000000..d63f75b3 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_defaults_snapshot@server_defaults_Gorm.snap @@ -0,0 +1,18 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +import ( + "time" +) + +type WithDefaults struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + CreatedAt time.Time `gorm:"column:created_at;not null" json:"created_at"` + Status string `gorm:"column:status;not null;type:text;default:'active'" json:"status"` + Count int32 `gorm:"column:count;not null;default:0" json:"count"` +} + +func (WithDefaults) TableName() string { return "with_defaults" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__small_multi_schema_sequential_snapshot@small_multi_schema_sequential_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__small_multi_schema_sequential_snapshot@small_multi_schema_sequential_Gorm.snap new file mode 100644 index 00000000..facce268 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__small_multi_schema_sequential_snapshot@small_multi_schema_sequential_Gorm.snap @@ -0,0 +1,22 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type Users struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + DisplayName *string `gorm:"column:display_name;type:text" json:"display_name"` + Posts []Posts `gorm:"foreignKey:UserID" json:"-"` +} + +func (Users) TableName() string { return "users" } + +type Posts struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + UserID int32 `gorm:"column:user_id;not null" json:"user_id"` + User *Users `gorm:"foreignKey:UserID" json:"-"` + Title string `gorm:"column:title;not null;type:text" json:"title"` +} + +func (Posts) TableName() string { return "posts" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__string_default_snapshot@string_default_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__string_default_snapshot@string_default_Gorm.snap new file mode 100644 index 00000000..1787ade7 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__string_default_snapshot@string_default_Gorm.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type StringDefaults struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Status string `gorm:"column:status;not null;type:text;default:'active'" json:"status"` +} + +func (StringDefaults) TableName() string { return "string_defaults" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_level_pk_snapshot@table_level_pk_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_level_pk_snapshot@table_level_pk_Gorm.snap new file mode 100644 index 00000000..ee02262c --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_level_pk_snapshot@table_level_pk_Gorm.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +import ( + "github.com/google/uuid" +) + +type Orders struct { + ID uuid.UUID `gorm:"column:id;primaryKey;type:uuid" json:"id"` + CustomerID uuid.UUID `gorm:"column:customer_id;not null;type:uuid" json:"customer_id"` + Total float32 `gorm:"column:total;not null" json:"total"` +} + +func (Orders) TableName() string { return "orders" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_check_snapshot@table_with_check_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_check_snapshot@table_with_check_Gorm.snap new file mode 100644 index 00000000..8971a648 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_check_snapshot@table_with_check_Gorm.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type Products struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Price int32 `gorm:"column:price;not null" json:"price"` +} + +func (Products) TableName() string { return "products" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_composite_fk_snapshot@table_with_composite_fk_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_composite_fk_snapshot@table_with_composite_fk_Gorm.snap new file mode 100644 index 00000000..cb1df18c --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_composite_fk_snapshot@table_with_composite_fk_Gorm.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type LineItems struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + OrderID int32 `gorm:"column:order_id;not null" json:"order_id"` + OrderVersion int32 `gorm:"column:order_version;not null" json:"order_version"` + Sku string `gorm:"column:sku;not null;type:text" json:"sku"` + Orders *Orders `gorm:"foreignKey:OrderID,OrderVersion;references:ID,Version" json:"-"` +} + +func (LineItems) TableName() string { return "line_items" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_enum_snapshot@table_with_enum_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_enum_snapshot@table_with_enum_Gorm.snap new file mode 100644 index 00000000..37d30f8e --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_enum_snapshot@table_with_enum_Gorm.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type OrderStatus string + +const ( + OrderStatusPending OrderStatus = "pending" + OrderStatusShipped OrderStatus = "shipped" + OrderStatusDelivered OrderStatus = "delivered" +) + +type Orders struct { + ID int32 `gorm:"column:id;not null" json:"id"` + Status OrderStatus `gorm:"column:status;not null" json:"status"` +} + +func (Orders) TableName() string { return "orders" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_fk_snapshot@table_with_fk_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_fk_snapshot@table_with_fk_Gorm.snap new file mode 100644 index 00000000..4557fe39 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_fk_snapshot@table_with_fk_Gorm.snap @@ -0,0 +1,14 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type Posts struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + UserID int32 `gorm:"column:user_id;not null" json:"user_id"` + User *Users `gorm:"foreignKey:UserID" json:"-"` + Title string `gorm:"column:title;not null;type:text" json:"title"` +} + +func (Posts) TableName() string { return "posts" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_indexes_snapshot@table_with_indexes_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_indexes_snapshot@table_with_indexes_Gorm.snap new file mode 100644 index 00000000..60349eb6 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_indexes_snapshot@table_with_indexes_Gorm.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +import ( + "time" +) + +type Articles struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Title string `gorm:"column:title;not null;type:text;index:ix_articles__title" json:"title"` + CreatedAt time.Time `gorm:"column:created_at;not null;index:ix_articles__idx_articles_created_at" json:"created_at"` +} + +func (Articles) TableName() string { return "articles" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_integer_enum_snapshot@table_with_integer_enum_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_integer_enum_snapshot@table_with_integer_enum_Gorm.snap new file mode 100644 index 00000000..dd7299f9 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_integer_enum_snapshot@table_with_integer_enum_Gorm.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type PriorityLevel int + +const ( + PriorityLevelLow PriorityLevel = 0 + PriorityLevelMedium PriorityLevel = 10 + PriorityLevelHigh PriorityLevel = 20 +) + +type Tasks struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Priority PriorityLevel `gorm:"column:priority;not null" json:"priority"` +} + +func (Tasks) TableName() string { return "tasks" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unique_and_indexed_snapshot@unique_and_indexed_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unique_and_indexed_snapshot@unique_and_indexed_Gorm.snap new file mode 100644 index 00000000..3bd61fcf --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unique_and_indexed_snapshot@unique_and_indexed_Gorm.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type Users struct { + ID int32 `gorm:"column:id;not null" json:"id"` + Email string `gorm:"column:email;not null;unique;type:text" json:"email"` + Username string `gorm:"column:username;not null;unique;type:text" json:"username"` + Department *string `gorm:"column:department;type:text;index:ix_users__idx_department" json:"department"` + Status string `gorm:"column:status;not null;type:text;default:'active'" json:"status"` +} + +func (Users) TableName() string { return "users" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_constant_default_snapshot@unknown_constant_default_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_constant_default_snapshot@unknown_constant_default_Gorm.snap new file mode 100644 index 00000000..ce1d0604 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_constant_default_snapshot@unknown_constant_default_Gorm.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type UnknownDefault struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Value string `gorm:"column:value;not null;type:text;default:SOME_CONSTANT" json:"value"` +} + +func (UnknownDefault) TableName() string { return "unknown_default" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_function_default_snapshot@unknown_function_default_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_function_default_snapshot@unknown_function_default_Gorm.snap new file mode 100644 index 00000000..99bafbbf --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_function_default_snapshot@unknown_function_default_Gorm.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type UnknownDefaults struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Code string `gorm:"column:code;not null;type:text" json:"code"` +} + +func (UnknownDefaults) TableName() string { return "unknown_defaults" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_index_snapshot@unnamed_composite_index_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_index_snapshot@unnamed_composite_index_Gorm.snap new file mode 100644 index 00000000..47701521 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_index_snapshot@unnamed_composite_index_Gorm.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type UnnamedIndex struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + ColA int32 `gorm:"column:col_a;not null;index:ix_unnamed_index__col_a_col_b" json:"col_a"` + ColB int32 `gorm:"column:col_b;not null;index:ix_unnamed_index__col_a_col_b" json:"col_b"` +} + +func (UnnamedIndex) TableName() string { return "unnamed_index" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_unique_snapshot@unnamed_composite_unique_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_unique_snapshot@unnamed_composite_unique_Gorm.snap new file mode 100644 index 00000000..2d0972a6 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_unique_snapshot@unnamed_composite_unique_Gorm.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type UnnamedUnique struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + ColA int32 `gorm:"column:col_a;not null;uniqueIndex:uq_unnamed_unique__col_a_col_b" json:"col_a"` + ColB int32 `gorm:"column:col_b;not null;uniqueIndex:uq_unnamed_unique__col_a_col_b" json:"col_b"` +} + +func (UnnamedUnique) TableName() string { return "unnamed_unique" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_index_and_unique_snapshot@unnamed_index_and_unique_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_index_and_unique_snapshot@unnamed_index_and_unique_Gorm.snap new file mode 100644 index 00000000..40ee814f --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_index_and_unique_snapshot@unnamed_index_and_unique_Gorm.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +import ( + "time" +) + +type Events struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + VenueID int32 `gorm:"column:venue_id;not null;index:ix_events__date_venue_id;uniqueIndex:uq_events__date_venue_id" json:"venue_id"` + Date time.Time `gorm:"column:date;not null;type:date;index:ix_events__date_venue_id;uniqueIndex:uq_events__date_venue_id" json:"date"` +} + +func (Events) TableName() string { return "events" } diff --git a/crates/vespertide-exporter/tests/parallel_consolidated.rs b/crates/vespertide-exporter/tests/parallel_consolidated.rs index 693711e4..fb1b174e 100644 --- a/crates/vespertide-exporter/tests/parallel_consolidated.rs +++ b/crates/vespertide-exporter/tests/parallel_consolidated.rs @@ -9,6 +9,9 @@ use vespertide_exporter::Orm; #[case::sqlalchemy(Orm::SqlAlchemy)] #[case::sqlmodel(Orm::SqlModel)] #[case::jpa(Orm::Jpa)] +#[case::prisma(Orm::Prisma)] +#[case::drizzle(Orm::Drizzle)] +#[case::gorm(Orm::Gorm)] fn export_is_byte_identical_across_thread_counts(#[case] orm: Orm) { let schema = large_schema(100); @@ -39,6 +42,7 @@ fn render_schema(orm: Orm, schema: &[TableDef]) -> Result { } Orm::Prisma => vespertide_exporter::prisma::export(schema), Orm::Drizzle => vespertide_exporter::drizzle::export(schema), + Orm::Gorm => vespertide_exporter::gorm::export(schema), } } From dd2db4c7a37c896a2a96ff1a175350c5e63f6ef5 Mon Sep 17 00:00:00 2001 From: JaeHyunAn <98042706+yyuneu@users.noreply.github.com> Date: Sun, 20 Sep 2026 15:35:09 +0900 Subject: [PATCH 5/6] =?UTF-8?q?feat(cli):=20export=20--orm=20gorm=EC=9D=80?= =?UTF-8?q?=20models.go=20=ED=95=9C=20=ED=8C=8C=EC=9D=BC=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vespertide-cli/src/commands/export/mod.rs | 56 +++++++- .../src/commands/export/tests/gorm.rs | 22 ++++ .../src/commands/export/tests/mod.rs | 2 + .../src/commands/export/tests/models_file.rs | 121 ++++++++++++++++++ ...ackage_name_from_the_export_directory.snap | 13 ++ ..._model_directories_into_one_file@Gorm.snap | 22 ++++ 6 files changed, 233 insertions(+), 3 deletions(-) create mode 100644 crates/vespertide-cli/src/commands/export/tests/gorm.rs create mode 100644 crates/vespertide-cli/src/commands/export/tests/models_file.rs create mode 100644 crates/vespertide-cli/src/commands/export/tests/snapshots/vespertide_cli__commands__export__tests__gorm__export_gorm_takes_its_package_name_from_the_export_directory.snap create mode 100644 crates/vespertide-cli/src/commands/export/tests/snapshots/vespertide_cli__commands__export__tests__models_file__export_writes_nested_model_directories_into_one_file@Gorm.snap diff --git a/crates/vespertide-cli/src/commands/export/mod.rs b/crates/vespertide-cli/src/commands/export/mod.rs index 3d5e4f9b..16ec58a8 100644 --- a/crates/vespertide-cli/src/commands/export/mod.rs +++ b/crates/vespertide-cli/src/commands/export/mod.rs @@ -8,8 +8,8 @@ use tokio::fs; use vespertide_config::VespertideConfig; use vespertide_core::TableDef; use vespertide_exporter::{ - Orm, drizzle, prisma, python_naming::to_pascal_case, render_entity_with_schema, - seaorm::SeaOrmExporterWithConfig, + Orm, drizzle, gorm::GormExporterWithConfig, prisma, python_naming::to_pascal_case, + render_entity_with_schema, seaorm::SeaOrmExporterWithConfig, }; use vespertide_naming::{IdentifierStart, sanitize_identifier, seaorm_module_name}; @@ -35,13 +35,16 @@ pub async fn cmd_export(orm: Orm, export_dir: Option) -> Result<()> { let target_root = resolve_export_dir(export_dir, &config); - // Prisma and Drizzle use a single-file output strategy + // Prisma, Drizzle and GORM use a single-file output strategy if matches!(orm, Orm::Prisma) { return cmd_export_prisma(normalized_models, target_root).await; } if matches!(orm, Orm::Drizzle) { return cmd_export_drizzle(normalized_models, target_root).await; } + if matches!(orm, Orm::Gorm) { + return cmd_export_models_file(normalized_models, target_root).await; + } // Clean the export directory before regenerating prepare_export_dir(&target_root, orm).await?; @@ -465,6 +468,53 @@ async fn cmd_export_drizzle( Ok(()) } +/// First line of the file [`cmd_export_models_file`] writes, after the +/// language's comment token. +const GENERATED_MARKER: &str = "Code generated by vespertide. DO NOT EDIT."; + +/// GORM gets the whole schema as one `models.go`. Go reads a directory as one +/// package and a relation is rendered from both of its ends, so models spread +/// over directories would import each other in a cycle. +/// +/// A fixed file name also leaves nothing to sweep, so the user's own `.go` +/// files are never touched. The file itself is only overwritten when it +/// starts with [`GENERATED_MARKER`]. +async fn cmd_export_models_file( + normalized_models: Vec<(TableDef, PathBuf)>, + target_root: PathBuf, +) -> Result<()> { + let all_tables: Vec = normalized_models.iter().map(|(t, _)| t.clone()).collect(); + let code = GormExporterWithConfig::for_export_dir(&target_root) + .export(&all_tables) + .map_err(|e| anyhow::anyhow!(e))?; + let marker = format!("// {GENERATED_MARKER}"); + + let out_path = target_root.join("models.go"); + if let Ok(existing) = fs::read(&out_path).await + && !existing.starts_with(marker.as_bytes()) + { + anyhow::bail!( + "{} was not generated by vespertide; move it or choose another --export-dir", + out_path.display() + ); + } + + fs::create_dir_all(&target_root) + .await + .with_context(|| format!("create export dir {}", target_root.display()))?; + fs::write(&out_path, format!("{marker}\n\n{code}")) + .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, diff --git a/crates/vespertide-cli/src/commands/export/tests/gorm.rs b/crates/vespertide-cli/src/commands/export/tests/gorm.rs new file mode 100644 index 00000000..9cae6006 --- /dev/null +++ b/crates/vespertide-cli/src/commands/export/tests/gorm.rs @@ -0,0 +1,22 @@ +use super::*; +use insta::assert_snapshot; + +/// Go requires the `package` clause to match the directory the file lives in, +/// so the effective package name comes from the real write target rather than +/// the config's static default. Exporting into a non-default directory is what +/// tells the two apart. +#[tokio::test] +#[serial] +async fn export_gorm_takes_its_package_name_from_the_export_directory() { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + write_config(); + write_model(Path::new("models/widgets.json"), &sample_table("widgets")); + + cmd_export(Orm::Gorm, Some(PathBuf::from("generated/store"))) + .await + .unwrap(); + + let written = std_fs::read_to_string(PathBuf::from("generated/store/models.go")).unwrap(); + assert_snapshot!(written); +} diff --git a/crates/vespertide-cli/src/commands/export/tests/mod.rs b/crates/vespertide-cli/src/commands/export/tests/mod.rs index 5dc489fa..26d73d7a 100644 --- a/crates/vespertide-cli/src/commands/export/tests/mod.rs +++ b/crates/vespertide-cli/src/commands/export/tests/mod.rs @@ -7,6 +7,8 @@ pub(super) use tempfile::tempdir; pub(super) use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType, TableConstraint}; mod drizzle; +mod gorm; +mod models_file; mod prisma; fn write_config() { diff --git a/crates/vespertide-cli/src/commands/export/tests/models_file.rs b/crates/vespertide-cli/src/commands/export/tests/models_file.rs new file mode 100644 index 00000000..f587189d --- /dev/null +++ b/crates/vespertide-cli/src/commands/export/tests/models_file.rs @@ -0,0 +1,121 @@ +use super::*; +use insta::{assert_snapshot, with_settings}; + +/// `user`, and a `post` referencing it from another model directory. +fn write_models_across_directories() { + write_model(Path::new("models/admin/user.json"), &sample_table("user")); + + let mut post = sample_table("post"); + post.columns.push(ColumnDef { + name: "user_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }); + post.constraints.push(TableConstraint::ForeignKey { + name: Some("fk_post__user_id".into()), + columns: vec!["user_id".into()], + ref_table: "user".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + orphan_strategy: Default::default(), + }); + write_model(Path::new("models/blog/post.json"), &post); +} + +/// Model directories do not reach the output. A Go directory is one package, +/// so both tables land in the same file and the relation between them +/// resolves without an import. +#[rstest] +#[case::gorm(Orm::Gorm, "models.go")] +#[serial] +#[tokio::test] +async fn export_writes_nested_model_directories_into_one_file( + #[case] orm: Orm, + #[case] file: &str, +) { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + write_config(); + write_models_across_directories(); + + cmd_export(orm, None).await.unwrap(); + + let root = PathBuf::from("src/models"); + assert_eq!(std_fs::read_dir(&root).unwrap().count(), 1); + let written = std_fs::read_to_string(root.join(file)).unwrap(); + with_settings!({ snapshot_suffix => format!("{orm:?}") }, { + assert_snapshot!(written); + }); +} + +/// The export root doubles as a source directory — a Go package — and +/// nothing in it is swept: a re-export replaces the one file an earlier +/// export wrote and leaves the user's own files, nested ones included, alone. +#[rstest] +#[case::gorm(Orm::Gorm, "models.go", "repository.go", "cache/store.go")] +#[serial] +#[tokio::test] +async fn export_replaces_only_its_own_models_file( + #[case] orm: Orm, + #[case] file: &str, + #[case] sibling: &str, + #[case] nested: &str, +) { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + write_config(); + write_model(Path::new("models/user.json"), &sample_table("user")); + + let root = PathBuf::from("src/models"); + cmd_export(orm, None).await.unwrap(); + let first = std_fs::read_to_string(root.join(file)).unwrap(); + + std_fs::create_dir_all(root.join(nested).parent().unwrap()).unwrap(); + std_fs::write(root.join(sibling), "user code").unwrap(); + std_fs::write(root.join(nested), "user code").unwrap(); + write_model(Path::new("models/post.json"), &sample_table("post")); + cmd_export(orm, None).await.unwrap(); + + assert_ne!(std_fs::read_to_string(root.join(file)).unwrap(), first); + assert_eq!( + std_fs::read_to_string(root.join(sibling)).unwrap(), + "user code" + ); + assert_eq!( + std_fs::read_to_string(root.join(nested)).unwrap(), + "user code" + ); +} + +/// `models.go` is a name the user may already own, so a file that does not +/// open with the generated marker is reported instead of overwritten. +#[rstest] +#[case::gorm(Orm::Gorm, "models.go", "package models\n")] +#[serial] +#[tokio::test] +async fn export_refuses_a_models_file_it_did_not_write( + #[case] orm: Orm, + #[case] file: &str, + #[case] own: &str, +) { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + write_config(); + write_model(Path::new("models/user.json"), &sample_table("user")); + + let out = PathBuf::from("src/models").join(file); + std_fs::create_dir_all("src/models").unwrap(); + std_fs::write(&out, own).unwrap(); + + let err = cmd_export(orm, None).await.unwrap_err(); + + assert!(err.to_string().contains("was not generated by vespertide")); + assert_eq!(std_fs::read_to_string(&out).unwrap(), own); +} diff --git a/crates/vespertide-cli/src/commands/export/tests/snapshots/vespertide_cli__commands__export__tests__gorm__export_gorm_takes_its_package_name_from_the_export_directory.snap b/crates/vespertide-cli/src/commands/export/tests/snapshots/vespertide_cli__commands__export__tests__gorm__export_gorm_takes_its_package_name_from_the_export_directory.snap new file mode 100644 index 00000000..8114ddf7 --- /dev/null +++ b/crates/vespertide-cli/src/commands/export/tests/snapshots/vespertide_cli__commands__export__tests__gorm__export_gorm_takes_its_package_name_from_the_export_directory.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-cli/src/commands/export/tests/gorm.rs +expression: written +--- +// Code generated by vespertide. DO NOT EDIT. + +package store + +type Widgets struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` +} + +func (Widgets) TableName() string { return "widgets" } diff --git a/crates/vespertide-cli/src/commands/export/tests/snapshots/vespertide_cli__commands__export__tests__models_file__export_writes_nested_model_directories_into_one_file@Gorm.snap b/crates/vespertide-cli/src/commands/export/tests/snapshots/vespertide_cli__commands__export__tests__models_file__export_writes_nested_model_directories_into_one_file@Gorm.snap new file mode 100644 index 00000000..03abe44e --- /dev/null +++ b/crates/vespertide-cli/src/commands/export/tests/snapshots/vespertide_cli__commands__export__tests__models_file__export_writes_nested_model_directories_into_one_file@Gorm.snap @@ -0,0 +1,22 @@ +--- +source: crates/vespertide-cli/src/commands/export/tests/models_file.rs +expression: written +--- +// Code generated by vespertide. DO NOT EDIT. + +package models + +type User struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Posts []Post `gorm:"foreignKey:UserID" json:"-"` +} + +func (User) TableName() string { return "user" } + +type Post struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + UserID int32 `gorm:"column:user_id;not null" json:"user_id"` + User *User `gorm:"foreignKey:UserID" json:"-"` +} + +func (Post) TableName() string { return "post" } From 86ce9a1a41787a092e266cadbf833aaddc301904 Mon Sep 17 00:00:00 2001 From: JaeHyunAn <98042706+yyuneu@users.noreply.github.com> Date: Sun, 20 Sep 2026 15:35:11 +0900 Subject: [PATCH 6/6] =?UTF-8?q?docs:=20GORM=20=EB=B0=B1=EC=97=94=EB=93=9C?= =?UTF-8?q?=20=EB=B0=98=EC=98=81=EA=B3=BC=20changepack?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../changepack_log_5O2QrCyrfNoD1wBL403qE.json | 1 + AGENTS.md | 16 ++-- README.md | 3 +- bridge/node/README.md | 2 +- crates/vespertide-cli/AGENTS.md | 9 ++- crates/vespertide-exporter/AGENTS.md | 79 ++++++++++++++++--- crates/vespertide/src/lib.rs | 2 +- 7 files changed, 88 insertions(+), 24 deletions(-) create mode 100644 .changepacks/changepack_log_5O2QrCyrfNoD1wBL403qE.json diff --git a/.changepacks/changepack_log_5O2QrCyrfNoD1wBL403qE.json b/.changepacks/changepack_log_5O2QrCyrfNoD1wBL403qE.json new file mode 100644 index 00000000..8dc943c6 --- /dev/null +++ b/.changepacks/changepack_log_5O2QrCyrfNoD1wBL403qE.json @@ -0,0 +1 @@ +{"changes": {"crates/vespertide-cli/Cargo.toml": "Minor", "crates/vespertide-config/Cargo.toml": "Minor", "crates/vespertide-core/Cargo.toml": "Minor", "crates/vespertide-exporter/Cargo.toml": "Minor", "crates/vespertide-loader/Cargo.toml": "Minor", "crates/vespertide-lsp/Cargo.toml": "Minor", "crates/vespertide-macro/Cargo.toml": "Minor", "crates/vespertide-naming/Cargo.toml": "Minor", "crates/vespertide-planner/Cargo.toml": "Minor", "crates/vespertide-query/Cargo.toml": "Minor", "crates/vespertide/Cargo.toml": "Minor"}, "note": "GORM(Go) 익스포터를 7번째 ORM 백엔드로 추가. `Orm`이 exhaustive pub enum이라 `Orm::Gorm` 추가가 0.x 기준 breaking이고, vespertide-cli에 `export --orm gorm` 경로가 함께 들어간다(스키마 전체를 `models.go` 한 파일로 쓴다). vespertide-core의 `SimpleColumnType`·`ReferenceAction`에서 `#[non_exhaustive]`를 제거해 downstream이 exhaustive match를 쓸 수 있게 한다(기존 `_` arm은 `unreachable_patterns` 경고가 된다). published 크레이트를 전부 같은 Minor로 올리는 이유는 #185·#186과 동일하다: [workspace.dependencies]의 `=` 핀으로 물려 있어 일부만 올리면 핀과 크레이트 버전이 어긋나 resolve가 깨진다.", "date": "2026-09-15T07:40:25.0000000Z"} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 4e47698a..3b883cc6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ vespertide/ │ ├── vespertide-planner/ # Schema diffing, baseline reconstruction, validation │ ├── vespertide-query/ # SQL generation (Postgres/MySQL/SQLite) │ ├── vespertide-cli/ # CLI commands: init, diff, sql, revision, export -│ ├── vespertide-exporter/ # ORM codegen: SeaORM, SQLAlchemy, SQLModel, JPA, Prisma, Drizzle +│ ├── vespertide-exporter/ # ORM codegen: SeaORM, SQLAlchemy, SQLModel, JPA, Prisma, Drizzle, GORM │ ├── vespertide-loader/ # Filesystem loading of models/migrations │ ├── vespertide-config/ # vespertide.json configuration │ ├── vespertide-lsp/ # Language server: 13 LSP capabilities + HS-7~11 caching @@ -48,7 +48,7 @@ vespertide/ | Schema diffing | `vespertide-planner/src/diff/` | topological sort for FK deps | | SQL generation | `vespertide-query/src/sql/` | One file per action type | | CLI commands | `vespertide-cli/src/commands/` | `cmd_*` functions | -| ORM export | `vespertide-exporter/src/{seaorm,sqlalchemy,sqlmodel,jpa,prisma,drizzle}/` | Backend-specific generators | +| ORM export | `vespertide-exporter/src/{seaorm,sqlalchemy,sqlmodel,jpa,prisma,drizzle,gorm}/` | Backend-specific generators | | Compile-time macro | `vespertide-macro/src/lib.rs` | `vespertide_migration!` proc macro | | **LSP RingCache (HS-7~11)** | `vespertide-lsp/src/cache.rs` | Generic ring-buffer LRU shared across symbols/diagnostics/drift/semantic-token caches | | **LSP drift cache** | `vespertide-lsp/src/drift/cache.rs` | HS-10 drift cache implementation | @@ -170,7 +170,7 @@ See `docs/clippy-allow-audit.md` for the full audit history. | `QueryError::Other(...)` in new code | Emits deprecation warning. Use `SchemaError` / `InvalidColumnType` / `BackendError` / `UnsupportedAction` | | Exhaustive struct literal for `MigrationOptions` / `VespertideConfig` | `#[non_exhaustive]` — use `..Default::default()` | | Comparing newtype with `String::eq(&name.to_string(), "user")` | `TableName: PartialEq<&str>` — use `name == "user"` directly | -| Per-ORM exporter snapshot test (single ORM) | Use the 6-ORM `orm_cases!` macro; snapshots must cross-compare all ORMs | +| Per-ORM exporter snapshot test (single ORM) | Use the 7-ORM `orm_cases!` macro; snapshots must cross-compare all ORMs | ## COMMANDS @@ -235,7 +235,7 @@ Files near the ceiling (next split candidates — line counts as of the | `query/src/sql/delete_column/mod.rs` | 1138 | prod+inline-tests (≤1200) | DROP COLUMN with SQLite rebuild | | `query/src/sql/add_constraint/mod.rs` | 1138 | prod+inline-tests (≤1200) | ADD CONSTRAINT | | `core/src/schema/table/tests/mod.rs` | 1137 | test-file (≤1200) | Table normalization tests | -| `exporter/src/tests/fixtures/mod.rs` | 1146 | test-file (≤1200) | Shared 6-ORM fixture schemas | +| `exporter/src/tests/fixtures/mod.rs` | 1168 | test-file (≤1200) | Shared 7-ORM fixture schemas | | `planner/src/validate/check_strengthening.rs` | 1121 | prod+inline-tests (≤1200) | CHECK strengthening analysis | | `query/src/sql/helpers.rs` | 1109 | prod+inline-tests (≤1200) | Identifier quoting / type-cast helpers | | `lsp/src/code_actions.rs` | 1107 | prod+inline-tests (≤1200) | LSP code actions (incl. CHECK BETWEEN-swap) | @@ -369,7 +369,7 @@ alongside what the dialect emits *in place of* the missing construct. **How many cases:** Where the axis has a documented matrix — `vespertide-query`'s -`{PG, MySQL, SQLite}` triple and the exporter's six-ORM `orm_cases!` — fan out +`{PG, MySQL, SQLite}` triple and the exporter's seven-ORM `orm_cases!` — fan out **always**, even when every case renders the same bytes: identity across the matrix is itself the assertion (`uniform_sql_is_emitted_byte_for_byte`), and a lone single-backend snapshot is a fault (`vespertide-query/AGENTS.md`). @@ -397,14 +397,14 @@ fn create_table_snapshot(#[case] backend: DatabaseBackend) { ``` This is the same pattern used by `vespertide-query` (3 backends, 564 snapshots) -and `vespertide-exporter` (6 ORMs via `Orm` enum, 414 cross-ORM snapshots). When +and `vespertide-exporter` (7 ORMs via `Orm` enum, 525 cross-ORM snapshots). When adding a new backend / ORM / format, the change is **one `#[case::name(Value)]` line**. ### Exporter snapshots MUST cover ALL ORMs (no per-ORM snapshots) -Every `vespertide-exporter` snapshot test MUST be written through the shared `orm_cases!` rstest macro in `crates/vespertide-exporter/src/tests/mod.rs`, which renders each fixture for **all six ORMs** (`Orm::SeaOrm`, `Orm::SqlAlchemy`, `Orm::SqlModel`, `Orm::Jpa`, `Orm::Prisma`, `Orm::Drizzle`). A new export scenario = ONE fixture + ONE `orm_cases!(...)` line, producing exactly six snapshots (one per ORM) in the single shared `crates/vespertide-exporter/src/tests/snapshots/` directory. +Every `vespertide-exporter` snapshot test MUST be written through the shared `orm_cases!` rstest macro in `crates/vespertide-exporter/src/tests/mod.rs`, which renders each fixture for **all seven ORMs** (`Orm::SeaOrm`, `Orm::SqlAlchemy`, `Orm::SqlModel`, `Orm::Jpa`, `Orm::Prisma`, `Orm::Drizzle`, `Orm::Gorm`). A new export scenario = ONE fixture + ONE `orm_cases!(...)` line, producing exactly seven snapshots (one per ORM) in the single shared `crates/vespertide-exporter/src/tests/snapshots/` directory. -FORBIDDEN: per-ORM `#[test]` snapshot functions inside `src/seaorm/`, `src/sqlalchemy/`, `src/sqlmodel/`, `src/jpa/`, `src/prisma/`, `src/drizzle/`, or any `snapshots/` directory other than `src/tests/snapshots/`. A scenario snapshotted for only one ORM is a defect — ORM output must always be cross-compared across all six. When adding a new ORM the change is a single `#[case::(Orm::)]` line in the macro, never a new per-ORM test. +FORBIDDEN: per-ORM `#[test]` snapshot functions inside `src/seaorm/`, `src/sqlalchemy/`, `src/sqlmodel/`, `src/jpa/`, `src/prisma/`, `src/drizzle/`, `src/gorm/`, or any `snapshots/` directory other than `src/tests/snapshots/`. A scenario snapshotted for only one ORM is a defect — ORM output must always be cross-compared across all seven. When adding a new ORM the change is a single `#[case::(Orm::)]` line in the macro, never a new per-ORM test. Exception: an entry point that exists in only one backend (Prisma's single-file `render_schema`, which deduplicates enums globally; Drizzle's dialect-aware `render_schema`, whose axis is the SQL dialect rather than the ORM) is not a cross-ORM scenario, so its snapshot tests live as inline tests of that module — with the snapshot files still written to the shared `src/tests/snapshots/` via `with_settings!(snapshot_path => ...)`. diff --git a/README.md b/README.md index 0b1ac708..d91d7e2a 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Declarative database schema management. Define your schemas in JSON, and Vespert - **Enum Types**: Native string enums and integer enums (no migration needed for new values) - **Zero-Runtime Migrations**: Compile-time macro generates database-specific SQL - **JSON Schema Validation**: Ships with JSON Schemas for IDE autocompletion and validation -- **ORM Export**: Export schemas to SeaORM, SQLAlchemy, SQLModel, JPA, Prisma, Drizzle +- **ORM Export**: Export schemas to SeaORM, SQLAlchemy, SQLModel, JPA, Prisma, Drizzle, GORM - **Language Server**: First-class editor support via the bundled `vespertide-lsp` — see [LSP Features](#lsp-features) below ## What's new in 0.2.0 @@ -245,6 +245,7 @@ vespertide export --orm sqlmodel # Python - SQLModel (FastAPI) vespertide export --orm jpa # Java - JPA/Hibernate entities vespertide export --orm prisma # Prisma - schema.prisma models vespertide export --orm drizzle # TypeScript - Drizzle ORM (pg/mysql/sqlite files) +vespertide export --orm gorm # Go - GORM models (models.go) ``` ## Runtime Migrations (Macro) diff --git a/bridge/node/README.md b/bridge/node/README.md index 864c4738..c6989ba8 100644 --- a/bridge/node/README.md +++ b/bridge/node/README.md @@ -2,7 +2,7 @@ Declarative database schema management: define tables in JSON or YAML, diff them against the migration history, and generate SQL for PostgreSQL, MySQL -and SQLite plus ORM code (SeaORM, SQLAlchemy, SQLModel, JPA, Prisma, Drizzle). +and SQLite plus ORM code (SeaORM, SQLAlchemy, SQLModel, JPA, Prisma, Drizzle, GORM). This package is the `vespertide` command-line tool as a native Node addon, so no Rust toolchain is needed. diff --git a/crates/vespertide-cli/AGENTS.md b/crates/vespertide-cli/AGENTS.md index 118ce130..0ac5e797 100644 --- a/crates/vespertide-cli/AGENTS.md +++ b/crates/vespertide-cli/AGENTS.md @@ -21,8 +21,9 @@ src/ │ # choices_and_apply/), tests/ ├── status.rs # Show config and sync status ├── log.rs # List applied migrations with SQL - ├── export/ # Export to ORM code (SeaORM/SQLAlchemy/SQLModel/JPA/Prisma/Drizzle) — - │ # mod.rs + tests/ (mod.rs, prisma.rs, drizzle.rs) + ├── export/ # Export to ORM code (SeaORM/SQLAlchemy/SQLModel/JPA/Prisma/Drizzle/GORM) — + │ # mod.rs + tests/ (mod.rs, prisma.rs, drizzle.rs, gorm.rs, + │ # models_file.rs) └── erd/ # ERD diagram export — mod.rs, mermaid.rs, dot.rs, svg/ (style, model, # layout, edges, render, util), tests/ ``` @@ -38,7 +39,7 @@ src/ | `revision -m` | `cmd_revision(msg, fill_with)` | Interactive prompts via `dialoguer::Input` | | `status` | `cmd_status()` | Display config paths and migration count | | `log` | `cmd_log(backend)` | Iterate applied migrations, print SQL | -| `export --orm` | `cmd_export(orm, dir)` | `render_entity_with_schema()` + mod.rs wiring | +| `export --orm` | `cmd_export(orm, dir)` | per-table `render_entity_with_schema()` + mod.rs wiring; Prisma/Drizzle/GORM render the whole schema into fixed file names | | `erd -f svg\|mermaid\|dot` | `cmd_erd_with_filters(format, output, include, exclude, depth)` | FK-graph filtered ERD rendering | ## WHERE TO LOOK @@ -55,7 +56,7 @@ src/ ## NOTES - **revision/**: Most complex command — handles interactive `--fill-with` prompts for NOT NULL columns without defaults; long ago split from a single 3064-line file into `revision/{mod,parse,emit,write,timezones}.rs` + `prompts/` + `tests/` -- **export/**: Generates the `mod.rs` chain for SeaORM exports; Python/Java ORMs skip it. Prisma and Drizzle take separate single-file paths rather than one file per model — Prisma writes one `models.prisma`, Drizzle one file per dialect (`models.pg.ts` / `models.mysql.ts` / `models.sqlite.ts`) +- **export/**: Generates the `mod.rs` chain for SeaORM exports; Python/Java ORMs skip it. Prisma, Drizzle and GORM take separate single-file paths rather than one file per model — Prisma writes one `models.prisma`, Drizzle one file per dialect (`models.pg.ts` / `models.mysql.ts` / `models.sqlite.ts`), GORM `models.go`. The last one skips the extension sweep like Drizzle, and refuse to overwrite a `models.*` that does not open with the `Code generated by vespertide. DO NOT EDIT.` line - All commands use `load_config()`, `load_models()`, `load_migrations()` from `vespertide_loader` - YAML and JSON are both fully supported for models and migrations; `new -f yaml` creates YAML templates. - Prefer typed `MigrationAction` enums; `RawSql` exists as a documented emergency escape hatch, but is not recommended for normal use. diff --git a/crates/vespertide-exporter/AGENTS.md b/crates/vespertide-exporter/AGENTS.md index 77d6275d..f6abe5fa 100644 --- a/crates/vespertide-exporter/AGENTS.md +++ b/crates/vespertide-exporter/AGENTS.md @@ -1,19 +1,21 @@ # vespertide-exporter -ORM code generation from `TableDef` schemas → SeaORM (Rust), SQLAlchemy (Python), SQLModel (Python), JPA (Java), Prisma (schema.prisma), Drizzle (TypeScript). +ORM code generation from `TableDef` schemas → SeaORM (Rust), SQLAlchemy (Python), SQLModel (Python), JPA (Java), Prisma (schema.prisma), Drizzle (TypeScript), GORM (Go). ## STRUCTURE ``` src/ ├── lib.rs # Re-exports all backends -├── orm.rs # OrmExporter trait, Orm enum (SeaOrm/SqlAlchemy/SqlModel/Jpa/Prisma/Drizzle), +├── orm.rs # OrmExporter trait, Orm enum (SeaOrm/SqlAlchemy/SqlModel/Jpa/Prisma/Drizzle/Gorm), │ # Orm::file_extension(), dispatch ├── constraint_scan.rs # Shared constraint scans + FK relation naming -│ # (fk_relation_names/relation_segment/collect_back_relations) -├── enum_scan.rs # Shared per-table enum-column scan (Prisma/Drizzle) +│ # (single_column_fk_details/junction_targets/fk_relation_names/relation_segment/ +│ # collect_back_relations) +├── enum_scan.rs # Shared enum-column scans (Prisma/Drizzle/GORM) ├── parallel_config.rs # Rayon parallelism thresholds -├── python_naming.rs # Shared Python PascalCase naming (SQLAlchemy/SQLModel/JPA/CLI) +├── python_naming.rs # Shared PascalCase naming (SQLAlchemy/SQLModel/JPA/GORM/CLI) +├── scope_names.rs # Top-level names claimed once per schema (GORM package) ├── seaorm/ # mod.rs, render.rs, types.rs, enums.rs, imports.rs, │ # relations/ (fk_resolve, naming, self_ref, reverse), tests/ ├── sqlalchemy/ # mod.rs, render.rs, types.rs, enums.rs — declarative_base models @@ -21,14 +23,17 @@ src/ ├── jpa/ # mod.rs, render.rs, types.rs — JPA/Hibernate entities ├── prisma/ # mod.rs, render.rs, types.rs, enums.rs — schema.prisma models ├── drizzle/ # mod.rs, render.rs, types.rs, enums.rs — Drizzle TypeScript models -├── utils/ # common.rs (join_quoted/unquote/claim_field_name), python.rs, -│ # typescript.rs (ts_binding/ts_string) +├── gorm/ # mod.rs, render.rs, types.rs, enums.rs — GORM structs +├── utils/ # common.rs (join_quoted/string_literal/unquote/claim_field_name/claim_binding/collect_composite_fks/is_jsonb_custom_type), +│ # python.rs (render_enum/column_type_to_python), +│ # typescript.rs (ts_binding) └── tests/ # Shared orm_cases! cross-ORM snapshot suite + fixtures/ + snapshots/ ``` Identifier escaping is centralized in `vespertide-naming`: `sanitize_identifier` with `IdentifierStart::Underscore` (Java, SQLAlchemy, ERD) or -`IdentifierStart::Letter` (SeaORM, SQLModel/Pydantic, Prisma, Drizzle), plus +`IdentifierStart::Letter` (SeaORM, SQLModel/Pydantic, Prisma, Drizzle, and GORM, +which also upper-cases the first letter because Go exports by case), plus `seaorm_module_name` and `to_screaming_snake_case`. A backend that renames an identifier MUST also emit the original database name (`@map`, `column_name`, SQLAlchemy's positional column name). @@ -69,6 +74,62 @@ SQLAlchemy's positional column name). - Enum types render as Java `enum` + `@Enumerated` - FK columns render as `@ManyToOne`/`@JoinColumn` relations +### GORM (Go) +- **Forward FK**: single-column FK → belongs-to struct field with a `gorm:"foreignKey:..."` tag; + composite (multi-column) FK → single relation field via comma-separated + `foreignKey:Col1,Col2;references:RefCol1,RefCol2`. A single-column key names `references:` too + when it points at anything but the target's primary key, which is what GORM would assume. + The field is always a pointer (`*User`), nullable or not: held by value, a struct could not + reference itself or a struct that references it back (`invalid recursive type`) +- **Reverse (has-one / has-many)**: built on the shared `constraint_scan::collect_back_relations`, + so composite FKs get a reverse side and a one-to-one — a key that is the source's whole + primary key, or that a unique covers exactly — renders as `*T` under the source struct's + name instead of `[]T` under its plural. Tags mirror the forward side. A **self-referencing FK** + (e.g. `categories.parent_id -> categories.id`) is named `Children` rather than a pluralized + table name to avoid colliding with the struct's own name; names that would repeat gain a + `By{key fields}` suffix (`SettingsByCreatedByUserID`) +- **No M2M/junction detection**: a junction table (composite-PK, 2+ FKs) is rendered as a plain + has-many to the junction struct itself, not a dedicated M2M relation +- **Identifiers**: every struct, field and type name is an exported Go name (`exported_go_name`: + `1users` → `X1users`), `Id` becomes `ID` only where it ends a word (`UserID`, but `Identity`), + and one taken set per struct covers the columns first and then every relation field, so a + has-many or belongs-to never takes a column's name (`Posts2`, `OrderRegions3`). That set starts + with `TableName`, the method every struct gets (a `table_name` column becomes `TableName2`, and + so does the belongs-to of a `table_name_id` key) +- **Package scope**: structs, enum types and enum constants all live in one Go package, so + `scope_names::ScopeNames` claims them once for the whole schema — structs first, then enum + types (bare while nothing else holds the identifier, otherwise `{Struct}{Enum}`), then + constants (`{Type}{Variant}`; values that fold onto one name are numbered). A table `role` next + to an enum `role`, or `Status` + `code` next to a `status_code` table, no longer redeclares. + A single-table render claims the same way over `scope_names::scope_of` — the schema when it + holds the table, the table alone otherwise +- **Tags**: `index:`/`uniqueIndex:` names come from the naming builders, so they match what the + SQL layer creates and GORM groups a composite index by them; `char(N)` and the PG network types + carry an explicit `type:`; a default GORM's tag syntax cannot hold (`;`, a function call) + is omitted, an integer enum's variant-name default becomes its value, and a string field's + default loses the doubled SQL quote (`'it''s'` → `'it's'`): GORM reads it as the value, while + every other field's default stays the SQL it is. GORM trims every quote off both ends of that + value, so a string default that starts or ends with `'` or `"` is omitted too. `struct_tag` quotes + each tag value as the Go string `reflect.StructTag` reads, so a `"` or `\` in a column name or + default is escaped, and the whole tag is an interpreted string when a value holds a backtick +- **Package name**: there is no `gorm` config section. `GormExporterWithConfig::for_export_dir` + derives it from the directory the file is written to (`go_package_name`) — the export + directory's final path segment sanitized into a Go identifier, falling back to `"models"`. The + CLI passes the real write target (`--export-dir` override or `model_export_dir`) because Go + expects `package` to name the directory the file lives in. +- **One file**: `GormExporterWithConfig::export` renders the whole schema as one source file, and + that is what the CLI writes (`models.go`). A Go directory is one package and a relation is + rendered from both of its ends, so models spread over directories would import each other in + a cycle +- **Layout**: `gofmt_layout` is the last step of every render — tab indents, struct-field and + constant columns padded the way `gofmt` aligns them, single blank lines — and `render_header` + lists each import group in sorted order, so the file passes a project's `gofmt -l` check as + written +- **Tests**: rendered output is pinned by the shared `orm_cases!` suite; the inline + `#[cfg(test)] mod tests` blocks hold only function-level unit tests (`types.rs` Go type + mapping; `render.rs` field and relation naming, package-scope constants, struct-tag escaping, + default tags; `mod.rs` package-name inference) + ### Prisma (schema.prisma) - Emits models only — no `datasource`/`generator` block, so the output drops into an existing schema - Backend-neutral: no provider-specific `@db.*` native attributes are emitted @@ -107,7 +168,7 @@ cargo insta accept - Snapshot testing with `insta` crate (YAML format) - `rstest` for parameterized tests across all ORM backends - Drizzle's cross-ORM snapshots carry the dialect the trait path renders (`…_Drizzle_pg.snap`); the other two dialects live in the module's own `render_schema_full_file_per_dialect@{pg,mysql,sqlite}` snapshots -- 428 snapshot files, all in the single shared `src/tests/snapshots/` directory; every export scenario goes through the shared `orm_cases!` macro in `src/tests/mod.rs`, producing one snapshot per ORM (all six) — a scenario snapshotted for only one ORM is a defect +- 539 snapshot files, all in the single shared `src/tests/snapshots/` directory; every export scenario goes through the shared `orm_cases!` macro in `src/tests/mod.rs`, producing one snapshot per ORM (all seven) — a scenario snapshotted for only one ORM is a defect ## NOTES diff --git a/crates/vespertide/src/lib.rs b/crates/vespertide/src/lib.rs index df057694..c157dae9 100644 --- a/crates/vespertide/src/lib.rs +++ b/crates/vespertide/src/lib.rs @@ -2,7 +2,7 @@ //! //! Declarative database schema management for Rust. Define schemas in JSON, //! generate migration plans, emit SQL for PostgreSQL/MySQL/SQLite, and export -//! ORM models for SeaORM/SQLAlchemy/SQLModel/JPA. +//! ORM models for SeaORM/SQLAlchemy/SQLModel/JPA/Prisma/Drizzle/GORM. //! //! This is the facade crate; runtime migrations use [`vespertide_migration!`]. //! Advanced users may depend on `vespertide-core` directly for typed data structures.