From 8febffc866d60d3238756d9e4a4e4b5e30ce884e Mon Sep 17 00:00:00 2001 From: 2heunxun Date: Thu, 27 Aug 2026 16:15:40 +0900 Subject: [PATCH 01/12] =?UTF-8?q?feat(exporter):=20GORM=20=EC=9D=B5?= =?UTF-8?q?=EC=8A=A4=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 02/12] =?UTF-8?q?refactor(core):=20SimpleColumnType=C2=B7R?= =?UTF-8?q?eferenceAction=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 03/12] =?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 04/12] =?UTF-8?q?feat(exporter):=20GORM=20=EB=B0=B1?= =?UTF-8?q?=EC=97=94=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 05/12] =?UTF-8?q?feat(cli):=20export=20--orm=20gorm?= =?UTF-8?q?=EC=9D=80=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 06/12] =?UTF-8?q?docs:=20GORM=20=EB=B0=B1=EC=97=94?= =?UTF-8?q?=EB=93=9C=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. From 0d1ba987f9c5d59ca1baa2c0a19557fbe383e6fb Mon Sep 17 00:00:00 2001 From: 2heunxun Date: Thu, 27 Aug 2026 16:15:40 +0900 Subject: [PATCH 07/12] =?UTF-8?q?feat(exporter):=20Django=20=EC=9D=B5?= =?UTF-8?q?=EC=8A=A4=ED=8F=AC=ED=84=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vespertide-exporter/src/django/enums.rs | 25 + crates/vespertide-exporter/src/django/mod.rs | 892 ++++++++++++++++++ .../vespertide-exporter/src/django/render.rs | 671 +++++++++++++ ..._exporter__django__tests__basic_table.snap | 19 + ...exporter__django__tests__composite_pk.snap | 18 + ...__tests__indexes_and_composite_unique.snap | 24 + ...jango__tests__server_default_timezone.snap | 18 + ...xporter__django__tests__table_with_fk.snap | 17 + ...jango__tests__table_with_integer_enum.snap | 21 + ...django__tests__table_with_string_enum.snap | 21 + .../vespertide-exporter/src/django/types.rs | 200 ++++ 11 files changed, 1926 insertions(+) create mode 100644 crates/vespertide-exporter/src/django/enums.rs create mode 100644 crates/vespertide-exporter/src/django/mod.rs create mode 100644 crates/vespertide-exporter/src/django/render.rs create mode 100644 crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__basic_table.snap create mode 100644 crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__composite_pk.snap create mode 100644 crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__indexes_and_composite_unique.snap create mode 100644 crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__server_default_timezone.snap create mode 100644 crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__table_with_fk.snap create mode 100644 crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__table_with_integer_enum.snap create mode 100644 crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__table_with_string_enum.snap create mode 100644 crates/vespertide-exporter/src/django/types.rs diff --git a/crates/vespertide-exporter/src/django/enums.rs b/crates/vespertide-exporter/src/django/enums.rs new file mode 100644 index 00000000..85dae123 --- /dev/null +++ b/crates/vespertide-exporter/src/django/enums.rs @@ -0,0 +1,25 @@ +use vespertide_core::schema::column::EnumValues; + +use super::render::to_upper_snake_case; + +pub(super) fn render_enum(lines: &mut Vec, class_name: &str, values: &EnumValues) { + match values { + EnumValues::String(vals) => { + lines.push(format!("class {class_name}(models.TextChoices):")); + for val in vals { + let const_name = to_upper_snake_case(val); + lines.push(format!(" {const_name} = \"{val}\", \"{val}\"")); + } + } + EnumValues::Integer(vals) => { + lines.push(format!("class {class_name}(models.IntegerChoices):")); + for val in vals { + let const_name = to_upper_snake_case(&val.name); + lines.push(format!( + " {const_name} = {}, \"{}\"", + val.value, val.name + )); + } + } + } +} diff --git a/crates/vespertide-exporter/src/django/mod.rs b/crates/vespertide-exporter/src/django/mod.rs new file mode 100644 index 00000000..5174f2d6 --- /dev/null +++ b/crates/vespertide-exporter/src/django/mod.rs @@ -0,0 +1,892 @@ +mod enums; +mod render; +mod types; + +use crate::orm::OrmExporter; +use vespertide_config::DjangoConfig; +use vespertide_core::TableDef; + +pub use render::{ + export, export_with_config, render_entity, render_entity_with_schema, + render_entity_with_schema_and_config, +}; + +pub struct DjangoExporter; + +impl OrmExporter for DjangoExporter { + 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) + } +} + +/// Django exporter that honors `vespertide.json`'s `django` config section +/// (currently an optional `app_label` written into every model's `Meta` +/// class). Mirrors `seaorm::SeaOrmExporterWithConfig`. +pub struct DjangoExporterWithConfig<'a> { + pub config: &'a DjangoConfig, +} + +impl<'a> DjangoExporterWithConfig<'a> { + pub fn new(config: &'a DjangoConfig) -> Self { + Self { config } + } + + pub fn render_entity_with_schema( + &self, + table: &TableDef, + schema: &[TableDef], + ) -> Result { + render_entity_with_schema_and_config(table, schema, self.config.app_label()) + } +} + +#[cfg(test)] +pub(crate) fn to_pascal_case_for_tests(s: &str) -> String { + render::to_pascal_case(s) +} + +#[cfg(test)] +mod tests { + use super::*; + use insta::assert_snapshot; + use rstest::rstest; + use vespertide_core::schema::column::{EnumValues, SimpleColumnType}; + use vespertide_core::schema::constraint::TableConstraint; + use vespertide_core::{ + ColumnType, ComplexColumnType, DefaultValue, NumValue, ReferenceAction, TableDef, + }; + + fn col(name: &str, ty: ColumnType) -> vespertide_core::ColumnDef { + vespertide_core::ColumnDef::new(name, ty, false) + } + + fn nullable_col(name: &str, ty: ColumnType) -> vespertide_core::ColumnDef { + vespertide_core::ColumnDef::new(name, ty, true) + } + + fn auto_pk(columns: &[&str]) -> TableConstraint { + TableConstraint::PrimaryKey { + auto_increment: true, + columns: columns.iter().copied().map(Into::into).collect(), + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + } + } + + fn pk(columns: &[&str]) -> TableConstraint { + TableConstraint::PrimaryKey { + auto_increment: false, + columns: columns.iter().copied().map(Into::into).collect(), + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + } + } + + fn fk(col: &str, ref_table: &str, on_delete: Option) -> TableConstraint { + TableConstraint::ForeignKey { + name: None, + columns: vec![col.into()], + ref_table: ref_table.into(), + ref_columns: vec!["id".into()], + on_delete, + on_update: None, + orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), + } + } + + // ----------------------------------------------------------------------- + // Basic table with autoincrement PK + nullable field + // ----------------------------------------------------------------------- + + #[test] + fn test_basic_table() { + let table = TableDef { + name: "users".into(), + description: Some("User accounts".into()), + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col( + "email", + ColumnType::Complex(ComplexColumnType::Varchar { length: 255 }), + ), + nullable_col("name", ColumnType::Simple(SimpleColumnType::Text)), + ], + constraints: vec![ + auto_pk(&["id"]), + TableConstraint::Unique { + name: None, + columns: vec!["email".into()], + strategy: vespertide_core::UniqueConstraintStrategy::DeleteDuplicates { + keep: vespertide_core::KeepPolicy::First, + }, + }, + ], + }; + assert_snapshot!(render_entity(&table).unwrap()); + } + + // ----------------------------------------------------------------------- + // FK field: `_id` suffix stripping + // ----------------------------------------------------------------------- + + #[test] + fn test_table_with_fk() { + let table = TableDef { + name: "posts".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col("author_id", ColumnType::Simple(SimpleColumnType::Integer)), + col("title", ColumnType::Simple(SimpleColumnType::Text)), + ], + constraints: vec![ + auto_pk(&["id"]), + fk("author_id", "users", Some(ReferenceAction::Cascade)), + ], + }; + assert_snapshot!(render_entity(&table).unwrap()); + } + + // ----------------------------------------------------------------------- + // TextChoices enum + // ----------------------------------------------------------------------- + + #[test] + fn test_table_with_string_enum() { + let table = TableDef { + name: "orders".into(), + description: None, + columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer)), { + let mut c = col( + "status", + ColumnType::Complex(ComplexColumnType::Enum { + name: "order_status".into(), + values: EnumValues::String(vec![ + "pending".into(), + "shipped".into(), + "delivered".into(), + ]), + }), + ); + c.default = Some(DefaultValue::String("'pending'".into())); + c + }], + constraints: vec![auto_pk(&["id"])], + }; + assert_snapshot!(render_entity(&table).unwrap()); + } + + // ----------------------------------------------------------------------- + // IntegerChoices enum + // ----------------------------------------------------------------------- + + #[test] + fn test_table_with_integer_enum() { + let table = TableDef { + name: "tasks".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col( + "priority", + 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, + }, + ]), + }), + ), + ], + constraints: vec![pk(&["id"])], + }; + assert_snapshot!(render_entity(&table).unwrap()); + } + + // ----------------------------------------------------------------------- + // Composite PK + // ----------------------------------------------------------------------- + + #[test] + fn test_composite_pk() { + let table = TableDef { + name: "order_items".into(), + description: None, + columns: vec![ + col("order_id", ColumnType::Simple(SimpleColumnType::Integer)), + col("product_id", ColumnType::Simple(SimpleColumnType::Integer)), + col("quantity", ColumnType::Simple(SimpleColumnType::Integer)), + ], + constraints: vec![pk(&["order_id", "product_id"])], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains("pk = models.CompositePrimaryKey(\"order_id\", \"product_id\")"), + "expected Django 5.2+ CompositePrimaryKey declaration, got:\n{result}" + ); + assert!( + !result.contains("primary_key=True"), + "individual composite-PK columns must not also carry primary_key=True, got:\n{result}" + ); + assert_snapshot!(result); + } + + #[test] + fn test_composite_pk_of_fk_columns_uses_attname_not_field_name() { + // Composite PK made of FK columns: CompositePrimaryKey must reference + // the Django attname ("{field}_id"), not the stripped field name + // ("article"/"user") used for the ForeignKey attribute itself. + let table = TableDef { + name: "article_user".into(), + description: None, + columns: vec![ + col("article_id", ColumnType::Simple(SimpleColumnType::Integer)), + col("user_id", ColumnType::Simple(SimpleColumnType::Integer)), + ], + constraints: vec![ + pk(&["article_id", "user_id"]), + fk("article_id", "articles", Some(ReferenceAction::Cascade)), + fk("user_id", "users", Some(ReferenceAction::Cascade)), + ], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains("pk = models.CompositePrimaryKey(\"article_id\", \"user_id\")"), + "expected attname-based CompositePrimaryKey args, got:\n{result}" + ); + } + + // ----------------------------------------------------------------------- + // Indexes and composite unique in Meta + // ----------------------------------------------------------------------- + + #[test] + fn test_indexes_and_composite_unique() { + let table = TableDef { + name: "articles".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col( + "slug", + ColumnType::Complex(ComplexColumnType::Varchar { length: 200 }), + ), + col("author_id", ColumnType::Simple(SimpleColumnType::Integer)), + col( + "created_at", + ColumnType::Simple(SimpleColumnType::Timestamptz), + ), + ], + constraints: vec![ + auto_pk(&["id"]), + TableConstraint::Index { + name: Some("ix_articles__created_at".into()), + columns: vec!["created_at".into()], + }, + TableConstraint::Unique { + name: Some("uq_articles__slug_author".into()), + columns: vec!["slug".into(), "author_id".into()], + strategy: vespertide_core::UniqueConstraintStrategy::DeleteDuplicates { + keep: vespertide_core::KeepPolicy::First, + }, + }, + ], + }; + assert_snapshot!(render_entity(&table).unwrap()); + } + + // ----------------------------------------------------------------------- + // server default (NOW()) → timezone.now + // ----------------------------------------------------------------------- + + #[test] + fn test_server_default_timezone() { + let table = TableDef { + name: "events".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + { + let mut c = col( + "created_at", + ColumnType::Simple(SimpleColumnType::Timestamptz), + ); + c.default = Some(DefaultValue::String("NOW()".into())); + c + }, + { + let mut c = col("count", ColumnType::Simple(SimpleColumnType::Integer)); + c.default = Some(DefaultValue::Integer(0)); + c + }, + ], + constraints: vec![auto_pk(&["id"])], + }; + let result = render_entity(&table).unwrap(); + assert!(result.contains("from django.utils import timezone")); + assert!(result.contains("default=timezone.now")); + assert!(result.contains("default=0")); + assert_snapshot!(result); + } + + // ----------------------------------------------------------------------- + // Type coverage — all simple types + // ----------------------------------------------------------------------- + + #[rstest] + #[case::small_int(SimpleColumnType::SmallInt, "models.SmallIntegerField")] + #[case::bigint(SimpleColumnType::BigInt, "models.BigIntegerField")] + #[case::real(SimpleColumnType::Real, "models.FloatField")] + #[case::text(SimpleColumnType::Text, "models.TextField")] + #[case::boolean(SimpleColumnType::Boolean, "models.BooleanField")] + #[case::date(SimpleColumnType::Date, "models.DateField")] + #[case::time(SimpleColumnType::Time, "models.TimeField")] + #[case::timestamp(SimpleColumnType::Timestamp, "models.DateTimeField")] + #[case::uuid(SimpleColumnType::Uuid, "models.UUIDField")] + #[case::json(SimpleColumnType::Json, "models.JSONField")] + #[case::bytea(SimpleColumnType::Bytea, "models.BinaryField")] + #[case::inet(SimpleColumnType::Inet, "models.GenericIPAddressField")] + #[case::interval(SimpleColumnType::Interval, "models.DurationField")] + #[case::macaddr(SimpleColumnType::Macaddr, "models.CharField")] + fn test_simple_type_mapping(#[case] ty: SimpleColumnType, #[case] expected: &str) { + let table = TableDef { + name: "t".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col("val", ColumnType::Simple(ty)), + ], + constraints: vec![pk(&["id"])], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains(expected), + "expected {expected} in:\n{result}" + ); + } + + #[rstest] + #[case::small_auto(SimpleColumnType::SmallInt, "models.SmallAutoField")] + #[case::big_auto(SimpleColumnType::BigInt, "models.BigAutoField")] + fn test_auto_pk_field_types(#[case] ty: SimpleColumnType, #[case] expected: &str) { + let table = TableDef { + name: "t".into(), + description: None, + columns: vec![col("id", ColumnType::Simple(ty))], + constraints: vec![auto_pk(&["id"])], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains(expected), + "expected {expected} in:\n{result}" + ); + } + + #[test] + fn test_numeric_field() { + 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![auto_pk(&["id"])], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains("models.DecimalField"), + "expected DecimalField" + ); + assert!(result.contains("max_digits=10"), "expected max_digits=10"); + assert!( + result.contains("decimal_places=2"), + "expected decimal_places=2" + ); + } + + #[test] + fn test_custom_type_field() { + let table = TableDef { + name: "docs".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col( + "data", + ColumnType::Complex(ComplexColumnType::Custom { + custom_type: "JSONB".into(), + }), + ), + ], + constraints: vec![auto_pk(&["id"])], + }; + let result = render_entity(&table).unwrap(); + // Custom type → models.TextField (Django has no native JSONB) + assert!( + result.contains("data = models.TextField()"), + "expected Custom→TextField in:\n{result}" + ); + } + + #[test] + fn test_uuid_default() { + let mut id_col = col("id", ColumnType::Simple(SimpleColumnType::Uuid)); + id_col.default = Some(DefaultValue::String("gen_random_uuid()".into())); + let table = TableDef { + name: "sessions".into(), + description: None, + columns: vec![id_col], + constraints: vec![pk(&["id"])], + }; + let result = render_entity(&table).unwrap(); + assert!(result.contains("import uuid"), "expected uuid import"); + assert!( + result.contains("default=uuid.uuid4"), + "expected uuid4 callable" + ); + } + + #[test] + fn test_export_multi_table() { + let users = TableDef { + name: "users".into(), + description: None, + columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], + constraints: vec![auto_pk(&["id"])], + }; + let posts = TableDef { + name: "posts".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col("author_id", ColumnType::Simple(SimpleColumnType::Integer)), + ], + constraints: vec![ + auto_pk(&["id"]), + fk("author_id", "users", Some(ReferenceAction::Cascade)), + ], + }; + let result = export(&[users, posts]).unwrap(); + assert!(result.contains("class Users(models.Model):")); + assert!(result.contains("class Posts(models.Model):")); + } + + #[test] + fn test_nullable_fk_with_db_column() { + // FK column without `_id` suffix → emits db_column kwarg + // Nullable FK → emits null=True, blank=True + let table = TableDef { + name: "comments".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + nullable_col("parent", ColumnType::Simple(SimpleColumnType::Integer)), + ], + constraints: vec![ + auto_pk(&["id"]), + fk("parent", "comments", Some(ReferenceAction::SetNull)), + ], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains(r#"db_column="parent""#), + "expected db_column kwarg" + ); + assert!( + result.contains("null=True"), + "expected null=True for nullable FK" + ); + assert!( + result.contains("blank=True"), + "expected blank=True for nullable FK" + ); + } + + // ----------------------------------------------------------------------- + // build_default: Boolean false → "False" + // ----------------------------------------------------------------------- + + #[test] + fn test_bool_false_default() { + let mut flag = col("enabled", ColumnType::Simple(SimpleColumnType::Boolean)); + flag.default = Some(DefaultValue::Bool(false)); + let table = TableDef { + name: "settings".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + flag, + ], + constraints: vec![auto_pk(&["id"])], + }; + let result = render_entity(&table).unwrap(); + assert!(result.contains("default=False"), "expected default=False"); + } + + // ----------------------------------------------------------------------- + // build_default: Boolean true → "True" + // ----------------------------------------------------------------------- + + #[test] + fn test_bool_true_default() { + let mut flag = col("enabled", ColumnType::Simple(SimpleColumnType::Boolean)); + flag.default = Some(DefaultValue::Bool(true)); + let table = TableDef { + name: "settings".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + flag, + ], + constraints: vec![auto_pk(&["id"])], + }; + let result = render_entity(&table).unwrap(); + assert!(result.contains("default=True"), "expected default=True"); + } + + // ----------------------------------------------------------------------- + // build_default: functional default on non-Timestamp/UUID type → None (omitted) + // ----------------------------------------------------------------------- + + #[test] + fn test_functional_default_non_special() { + let mut seq_id = col("seq_id", ColumnType::Simple(SimpleColumnType::Integer)); + seq_id.default = Some(DefaultValue::String("nextval('my_seq')".into())); + let table = TableDef { + name: "items".into(), + description: None, + columns: vec![seq_id], + constraints: vec![pk(&["seq_id"])], + }; + let result = render_entity(&table).unwrap(); + assert!( + !result.contains("default="), + "functional default should be omitted" + ); + } + + // ----------------------------------------------------------------------- + // reference_action_str: Restrict, SetDefault, NoAction + // ----------------------------------------------------------------------- + + #[rstest] + #[case(ReferenceAction::Restrict, "models.RESTRICT")] + #[case(ReferenceAction::SetDefault, "models.SET_DEFAULT")] + #[case(ReferenceAction::NoAction, "models.DO_NOTHING")] + fn test_fk_on_delete_actions(#[case] action: ReferenceAction, #[case] expected: &str) { + let table = TableDef { + name: "comments".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col("post_id", ColumnType::Simple(SimpleColumnType::Integer)), + ], + constraints: vec![auto_pk(&["id"]), fk("post_id", "posts", Some(action))], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains(expected), + "expected {expected} in:\n{result}" + ); + } + + // ----------------------------------------------------------------------- + // Column comment → emits "# ..." line before the field + // ----------------------------------------------------------------------- + + #[test] + fn test_column_comment() { + let mut c = col("name", ColumnType::Simple(SimpleColumnType::Text)); + c.comment = Some("The user's full name".into()); + let table = TableDef { + name: "users".into(), + description: None, + columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer)), c], + constraints: vec![auto_pk(&["id"])], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains(" # The user's full name"), + "expected column comment in output" + ); + } + + // ----------------------------------------------------------------------- + // Unnamed index and unnamed composite unique in Meta + // ----------------------------------------------------------------------- + + #[test] + fn test_index_and_unique_no_name() { + let table = TableDef { + name: "entries".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col( + "slug", + ColumnType::Complex(ComplexColumnType::Varchar { length: 100 }), + ), + col( + "tag", + ColumnType::Complex(ComplexColumnType::Varchar { length: 50 }), + ), + ], + constraints: vec![ + auto_pk(&["id"]), + TableConstraint::Index { + name: None, + columns: vec!["slug".into()], + }, + TableConstraint::Unique { + name: None, + columns: vec!["slug".into(), "tag".into()], + strategy: vespertide_core::UniqueConstraintStrategy::DeleteDuplicates { + keep: vespertide_core::KeepPolicy::First, + }, + }, + ], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains("models.Index(fields=[\"slug\"]),"), + "expected unnamed Index" + ); + assert!( + result.contains("models.UniqueConstraint(fields=[\"slug\", \"tag\"]),"), + "expected unnamed UniqueConstraint" + ); + } + + // ----------------------------------------------------------------------- + // Many-to-many junction table recognition (render_entity_with_schema) + // ----------------------------------------------------------------------- + + fn users_table() -> TableDef { + TableDef { + name: "users".into(), + description: None, + columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], + constraints: vec![auto_pk(&["id"])], + } + } + + fn tags_table() -> TableDef { + TableDef { + name: "tags".into(), + description: None, + columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], + constraints: vec![auto_pk(&["id"])], + } + } + + fn junction_table( + name: &str, + left_col: &str, + left_ref: &str, + right_col: &str, + right_ref: &str, + ) -> TableDef { + TableDef { + name: name.into(), + description: None, + columns: vec![ + col(left_col, ColumnType::Simple(SimpleColumnType::Integer)), + col(right_col, ColumnType::Simple(SimpleColumnType::Integer)), + ], + constraints: vec![ + pk(&[left_col, right_col]), + fk(left_col, left_ref, None), + fk(right_col, right_ref, None), + ], + } + } + + #[test] + fn test_many_to_many_junction_table() { + let users = users_table(); + let tags = tags_table(); + let user_tags = junction_table("user_tags", "user_id", "users", "tag_id", "tags"); + let schema = vec![users.clone(), tags.clone(), user_tags.clone()]; + + let result = render_entity_with_schema(&users, &schema).unwrap(); + assert!( + result.contains( + "tags = models.ManyToManyField(\"Tags\", through=\"UserTags\", related_name=\"+\")" + ), + "expected ManyToManyField on users side, got:\n{result}" + ); + + let result = render_entity_with_schema(&tags, &schema).unwrap(); + assert!( + result.contains( + "users = models.ManyToManyField(\"Users\", through=\"UserTags\", related_name=\"+\")" + ), + "expected ManyToManyField on tags side, got:\n{result}" + ); + } + + #[test] + fn test_many_to_many_disambiguates_multiple_junctions_to_same_target() { + let users = users_table(); + let tags = tags_table(); + let user_tags = junction_table("user_tags", "user_id", "users", "tag_id", "tags"); + let user_favorite_tags = + junction_table("user_favorite_tags", "user_id", "users", "tag_id", "tags"); + let schema = vec![users.clone(), tags, user_tags, user_favorite_tags]; + + let result = render_entity_with_schema(&users, &schema).unwrap(); + assert!( + result.contains( + "tags_via_user_tags = models.ManyToManyField(\"Tags\", through=\"UserTags\"" + ), + "expected disambiguated field for user_tags junction, got:\n{result}" + ); + assert!( + result.contains( + "tags_via_user_favorite_tags = models.ManyToManyField(\"Tags\", through=\"UserFavoriteTags\"" + ), + "expected disambiguated field for user_favorite_tags junction, got:\n{result}" + ); + } + + #[test] + fn test_purely_self_referential_junction_is_skipped() { + // "friends" links users to users on both sides — not a two-sided M2M + // we can safely name, so no ManyToManyField should be emitted. + let users = users_table(); + let friends = junction_table("friends", "user_id", "users", "friend_id", "users"); + let schema = vec![users.clone(), friends]; + + let result = render_entity_with_schema(&users, &schema).unwrap(); + assert!( + !result.contains("ManyToManyField"), + "self-referential junction must not produce a guessed M2M field, got:\n{result}" + ); + } + + #[test] + fn test_junction_table_unrelated_to_current_table_is_ignored() { + // "order_tags" is a genuine junction (composite PK, 2 FKs both in the + // PK), but neither side references `users` at all — it links + // "orders" and "tags" together, so it must not produce any + // ManyToManyField on `users`. + let users = users_table(); + let tags = tags_table(); + let orders = TableDef { + name: "orders".into(), + description: None, + columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], + constraints: vec![auto_pk(&["id"])], + }; + let order_tags = junction_table("order_tags", "order_id", "orders", "tag_id", "tags"); + let schema = vec![users.clone(), tags, orders, order_tags]; + + let result = render_entity_with_schema(&users, &schema).unwrap(); + assert!( + !result.contains("ManyToManyField"), + "junction table unrelated to `users` must not produce a M2M field, got:\n{result}" + ); + } + + #[test] + fn test_export_multi_table_includes_many_to_many() { + let users = users_table(); + let tags = tags_table(); + let user_tags = junction_table("user_tags", "user_id", "users", "tag_id", "tags"); + let result = export(&[users, tags, user_tags]).unwrap(); + assert!( + result.contains("models.ManyToManyField(\"Tags\", through=\"UserTags\""), + "expected ManyToManyField in multi-table export, got:\n{result}" + ); + } + + // ----------------------------------------------------------------------- + // Composite FK: Django has no native multi-column FK field, so it must + // be surfaced as a comment instead of silently dropped. + // ----------------------------------------------------------------------- + + #[test] + fn test_composite_fk_emits_comment() { + let table = 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![ + pk(&["order_id", "region_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: None, + on_update: None, + orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), + }, + ], + }; + let result = render_entity(&table).unwrap(); + assert!( + result.contains( + "# composite foreign key: (order_id, region_id) -> order_regions(order_id, region_id)" + ), + "expected composite FK comment, got:\n{result}" + ); + } + + // ----------------------------------------------------------------------- + // DjangoExporterWithConfig: app_label reaches the Meta class + // ----------------------------------------------------------------------- + + #[test] + fn test_app_label_omitted_by_default() { + let table = users_table(); + let schema = vec![table.clone()]; + let config = DjangoConfig::default(); + let exporter = DjangoExporterWithConfig::new(&config); + let result = exporter.render_entity_with_schema(&table, &schema).unwrap(); + assert!( + !result.contains("app_label"), + "expected no app_label with default config, got:\n{result}" + ); + } + + #[test] + fn test_app_label_from_config_reaches_meta_class() { + let table = users_table(); + let schema = vec![table.clone()]; + let mut config = DjangoConfig::default(); + config.app_label = Some("myapp".to_string()); + let exporter = DjangoExporterWithConfig::new(&config); + let result = exporter.render_entity_with_schema(&table, &schema).unwrap(); + assert!( + result.contains(" app_label = \"myapp\""), + "expected app_label in Meta class, got:\n{result}" + ); + } +} diff --git a/crates/vespertide-exporter/src/django/render.rs b/crates/vespertide-exporter/src/django/render.rs new file mode 100644 index 00000000..c855ac33 --- /dev/null +++ b/crates/vespertide-exporter/src/django/render.rs @@ -0,0 +1,671 @@ +use std::collections::{HashMap, HashSet}; + +use super::enums::render_enum; +use super::types::{UsedImports, build_field_kwargs, django_field_type, reference_action_str}; +use crate::utils::python::collect_composite_fks; +use vespertide_core::schema::column::{ColumnType, ComplexColumnType}; +use vespertide_core::schema::constraint::TableConstraint; +use vespertide_core::{ReferenceAction, TableDef}; +use vespertide_naming::{IdentifierStart, sanitize_identifier}; + +pub fn render_entity(table: &TableDef) -> Result { + let mut used = UsedImports::default(); + let body = render_entity_part(table, &mut used, &[], None); + Ok(assemble_with_imports(&used, &[body])) +} + +/// Render a single table with full schema context so many-to-many junction +/// tables can be recognized and exposed as `ManyToManyField(..., through=...)`. +pub fn render_entity_with_schema(table: &TableDef, schema: &[TableDef]) -> Result { + render_entity_with_schema_and_config(table, schema, None) +} + +/// Same as [`render_entity_with_schema`], but with an optional `app_label` +/// (from `vespertide.json`'s `django` config) written into every model's +/// `Meta` class. +pub fn render_entity_with_schema_and_config( + table: &TableDef, + schema: &[TableDef], + app_label: Option<&str>, +) -> Result { + let mut used = UsedImports::default(); + let m2m_fields = find_many_to_many_fields(table, schema); + let body = render_entity_part(table, &mut used, &m2m_fields, app_label); + Ok(assemble_with_imports(&used, &[body])) +} + +pub fn export(schema: &[TableDef]) -> Result { + export_with_config(schema, None) +} + +/// Same as [`export`], but with an optional `app_label` written into every +/// model's `Meta` class. +pub fn export_with_config(schema: &[TableDef], app_label: Option<&str>) -> Result { + let mut used = UsedImports::default(); + let parts: Vec = schema + .iter() + .map(|t| { + let m2m_fields = find_many_to_many_fields(t, schema); + render_entity_part(t, &mut used, &m2m_fields, app_label) + }) + .collect(); + Ok(assemble_with_imports(&used, &parts)) +} + +/// Recognize many-to-many junction tables (composite PK, 2+ FKs, all FK +/// columns part of the PK) that reference `table`, and render the +/// corresponding `ManyToManyField` lines for the *other* side of each +/// junction. Purely self-referential junctions (every FK pointing back at +/// `table`) are skipped rather than guessed at. +fn find_many_to_many_fields(table: &TableDef, schema: &[TableDef]) -> Vec { + let mut matches: Vec<(String, String)> = Vec::new(); // (target_table, junction_table) + + for other in schema { + if other.name == table.name { + continue; + } + + let other_pk: HashSet = other + .constraints + .iter() + .filter_map(|c| { + if let TableConstraint::PrimaryKey { columns, .. } = c { + Some( + columns + .iter() + .map(|c| c.as_str().to_owned()) + .collect::>(), + ) + } else { + None + } + }) + .flatten() + .collect(); + if other_pk.len() < 2 { + continue; + } + + let fks: Vec<(Vec, String)> = other + .constraints + .iter() + .filter_map(|c| { + if let TableConstraint::ForeignKey { + columns, ref_table, .. + } = c + { + Some(( + columns.iter().map(|c| c.as_str().to_owned()).collect(), + ref_table.as_str().to_owned(), + )) + } else { + None + } + }) + .collect(); + if fks.len() < 2 { + continue; + } + + let all_fk_cols_in_pk = fks + .iter() + .all(|(cols, _)| cols.iter().all(|c| other_pk.contains(c.as_str()))); + if !all_fk_cols_in_pk { + continue; + } + + if !fks + .iter() + .any(|(_, ref_table)| ref_table.as_str() == table.name.as_str()) + { + continue; + } + if fks + .iter() + .all(|(_, ref_table)| ref_table.as_str() == table.name.as_str()) + { + continue; + } + + for (_, ref_table) in &fks { + if ref_table.as_str() == table.name.as_str() { + continue; + } + if schema.iter().any(|t| t.name.as_str() == ref_table.as_str()) { + matches.push((ref_table.clone(), other.name.as_str().to_owned())); + } + } + } + + let mut target_counts: HashMap = HashMap::new(); + for (target, _) in &matches { + *target_counts.entry(target.clone()).or_default() += 1; + } + + let mut used_names: HashSet = HashSet::new(); + matches + .iter() + .map(|(target, junction)| { + let base = pluralize(target); + let field_name = if target_counts.get(target).copied().unwrap_or(0) > 1 { + unique_name(&format!("{base}_via_{junction}"), &mut used_names) + } else { + unique_name(&base, &mut used_names) + }; + let target_class = sanitize_identifier(&to_pascal_case(target), IdentifierStart::Underscore); + let junction_class = + sanitize_identifier(&to_pascal_case(junction), IdentifierStart::Underscore); + format!( + " {field_name} = models.ManyToManyField(\"{target_class}\", through=\"{junction_class}\", related_name=\"+\")" + ) + }) + .collect() +} + +fn pluralize(name: &str) -> String { + if name.ends_with('s') { + name.to_string() + } else { + format!("{name}s") + } +} + +fn unique_name(base: &str, used: &mut HashSet) -> String { + if used.insert(base.to_string()) { + return base.to_string(); + } + let mut n = 2; + loop { + let candidate = format!("{base}_{n}"); + if used.insert(candidate.clone()) { + return candidate; + } + n += 1; + } +} + +fn render_entity_part( + table: &TableDef, + used: &mut UsedImports, + extra_fields: &[String], + app_label: Option<&str>, +) -> String { + let mut lines: Vec = Vec::new(); + + // --- Constraint lookups --- + let pk_columns: HashSet = table + .constraints + .iter() + .filter_map(|c| { + if let TableConstraint::PrimaryKey { columns, .. } = c { + Some( + columns + .iter() + .map(|c| c.as_str().to_owned()) + .collect::>(), + ) + } else { + None + } + }) + .flatten() + .collect(); + + let auto_increment = table.constraints.iter().any(|c| { + matches!( + c, + TableConstraint::PrimaryKey { + auto_increment: true, + .. + } + ) + }); + + let is_composite_pk = pk_columns.len() > 1; + + // Column order (not just membership) matters for CompositePrimaryKey's + // positional args, so capture it separately from the `pk_columns` set. + let pk_columns_ordered: Vec = table + .constraints + .iter() + .find_map(|c| { + if let TableConstraint::PrimaryKey { columns, .. } = c { + Some(columns.iter().map(|c| c.as_str().to_owned()).collect()) + } else { + None + } + }) + .unwrap_or_default(); + + let single_unique_cols: 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(); + + // single-column FK info: col_name → (ref_table, on_delete, on_update) + let fk_map: HashMap, Option<&ReferenceAction>)> = table + .constraints + .iter() + .filter_map(|c| { + if let TableConstraint::ForeignKey { + columns, + ref_table, + ref_columns, + on_delete, + on_update, + .. + } = c + && columns.len() == 1 + && ref_columns.len() == 1 + { + return Some(( + columns[0].as_str().to_owned(), + (ref_table.as_str(), on_delete.as_ref(), on_update.as_ref()), + )); + } + None + }) + .collect(); + + // Enum class names for this table's columns + let enum_class_map: HashMap<&str, String> = table + .columns + .iter() + .filter_map(|col| { + if let ColumnType::Complex(ComplexColumnType::Enum { name, .. }) = &col.r#type { + Some(( + col.name.as_str(), + sanitize_identifier(&to_pascal_case(name), IdentifierStart::Underscore), + )) + } else { + None + } + }) + .collect(); + + // --- Enum class definitions --- + let mut seen_enums: HashSet = HashSet::new(); + for col in &table.columns { + if let ColumnType::Complex(ComplexColumnType::Enum { name, values }) = &col.r#type { + let class_name = + sanitize_identifier(&to_pascal_case(name), IdentifierStart::Underscore); + if seen_enums.insert(class_name.clone()) { + render_enum(&mut lines, &class_name, values); + lines.push(String::new()); + } + } + } + + // --- Class declaration --- + let class_name = sanitize_identifier(&to_pascal_case(&table.name), IdentifierStart::Underscore); + if let Some(ref desc) = table.description { + lines.push(format!("class {class_name}(models.Model):")); + lines.push(format!(" \"\"\"{}\"\"\"", desc.replace('\n', " "))); + lines.push(String::new()); + } else { + lines.push(format!("class {class_name}(models.Model):")); + } + + // Composite PK: Django (5.2+) represents this natively via + // `pk = models.CompositePrimaryKey(...)`, referencing each column by its + // attname (a ForeignKey's attname is always `{field_name}_id`, regardless + // of any `db_column` override). Without this, Django would fall back to + // adding its own implicit auto `id` PK, which doesn't correspond to any + // real uniqueness constraint on the actual table. + if is_composite_pk { + let attnames: Vec = pk_columns_ordered + .iter() + .map(|col| { + if fk_map.contains_key(col.as_str()) { + let (field_name, _) = fk_field_name(col); + format!("{field_name}_id") + } else { + col.clone() + } + }) + .collect(); + let args = attnames + .iter() + .map(|a| format!("\"{a}\"")) + .collect::>() + .join(", "); + lines.push(format!(" pk = models.CompositePrimaryKey({args})")); + } + + // --- Fields --- + // Sanitizing distinct column names (e.g. `a_id` -> `a`, `a` -> `a`) can + // collapse two originally-distinct columns onto the same Python + // attribute name; disambiguate with a numeric suffix rather than + // silently emitting a duplicate class attribute. + let mut used_field_names: HashSet = HashSet::new(); + for col in &table.columns { + let is_pk = pk_columns.contains(col.name.as_str()); + let is_unique = single_unique_cols.contains(col.name.as_str()); + + if let Some(ref comment) = col.comment { + lines.push(format!(" # {}", comment.replace('\n', " "))); + } + + if let Some(&(ref_table, on_delete, on_update)) = fk_map.get(col.name.as_str()) { + render_fk_field( + &mut lines, + &col.name, + ref_table, + on_delete, + on_update, + col.nullable, + &mut used_field_names, + ); + } else { + let effective_pk = is_pk && !is_composite_pk; + let field_type = django_field_type( + &col.r#type, + effective_pk, + auto_increment && !is_composite_pk, + ); + let field_name = unique_name( + &sanitize_identifier(col.name.as_str(), IdentifierStart::Underscore), + &mut used_field_names, + ); + let db_column = if field_name == col.name.as_str() { + None + } else { + Some(col.name.as_str()) + }; + let kwargs = build_field_kwargs( + &col.r#type, + effective_pk, + is_unique, + col.nullable, + col.default.as_ref(), + enum_class_map.get(col.name.as_str()).map(String::as_str), + db_column, + used, + ); + let kwargs_str = kwargs.join(", "); + if kwargs_str.is_empty() { + lines.push(format!(" {field_name} = {field_type}()")); + } else { + lines.push(format!(" {field_name} = {field_type}({kwargs_str})")); + } + } + } + + for line in extra_fields { + lines.push(line.clone()); + } + + // Composite (multi-column) FKs have no native Django ORM field — surface + // them as a comment rather than silently dropping the relationship info. + // The individual columns still render above as plain scalar fields, and + // referential integrity is enforced by the generated database schema. + for fk in collect_composite_fks(table) { + let local = fk.local_cols.join(", "); + let refs = fk.ref_cols.join(", "); + lines.push(format!( + " # composite foreign key: ({local}) -> {}({refs})", + fk.ref_table + )); + } + + // --- Meta class --- + let indexes: Vec<_> = table + .constraints + .iter() + .filter_map(|c| { + if let TableConstraint::Index { name, columns } = c { + Some((name.as_deref(), columns.as_slice())) + } else { + None + } + }) + .collect(); + + let composite_uniques: Vec<_> = table + .constraints + .iter() + .filter_map(|c| { + if let TableConstraint::Unique { name, columns, .. } = c { + if columns.len() > 1 { + Some((name.as_deref(), columns.as_slice())) + } else { + None + } + } else { + None + } + }) + .collect(); + + lines.push(String::new()); + lines.push(" class Meta:".into()); + lines.push(format!(" db_table = \"{}\"", table.name)); + if let Some(label) = app_label { + lines.push(format!(" app_label = \"{label}\"")); + } + + if !indexes.is_empty() { + lines.push(" indexes = [".into()); + for (name, cols) in &indexes { + let fields = cols + .iter() + .map(|c| format!("\"{c}\"")) + .collect::>() + .join(", "); + if let Some(n) = name { + lines.push(format!( + " models.Index(fields=[{fields}], name=\"{n}\")," + )); + } else { + lines.push(format!(" models.Index(fields=[{fields}]),")); + } + } + lines.push(" ]".into()); + } + + if !composite_uniques.is_empty() { + lines.push(" constraints = [".into()); + for (name, cols) in &composite_uniques { + let fields = cols + .iter() + .map(|c| format!("\"{c}\"")) + .collect::>() + .join(", "); + if let Some(n) = name { + lines.push(format!( + " models.UniqueConstraint(fields=[{fields}], name=\"{n}\")," + )); + } else { + lines.push(format!( + " models.UniqueConstraint(fields=[{fields}])," + )); + } + } + lines.push(" ]".into()); + } + + lines.push(String::new()); + lines.join("\n") +} + +fn render_fk_field( + lines: &mut Vec, + col_name: &str, + ref_table: &str, + on_delete: Option<&ReferenceAction>, + on_update: Option<&ReferenceAction>, + nullable: bool, + used_field_names: &mut HashSet, +) { + let (field_name, db_column) = fk_field_name(col_name); + // The `_id` strip can collapse two distinct columns onto the same + // attribute name (e.g. `a_id` -> `a` colliding with a real column `a`). + let deduped_field_name = unique_name(&field_name, used_field_names); + let db_column = + db_column.or_else(|| (deduped_field_name != field_name).then(|| col_name.to_string())); + let field_name = deduped_field_name; + let ref_class = sanitize_identifier(&to_pascal_case(ref_table), IdentifierStart::Underscore); + let on_delete_str = on_delete.map_or("models.RESTRICT", reference_action_str); + + let _ = on_update; // Django ForeignKey has no on_update param; silently ignored + + let mut kwargs = vec![ + format!("\"{ref_class}\""), + format!("on_delete={on_delete_str}"), + ]; + if let Some(db_col) = db_column { + kwargs.push(format!("db_column=\"{db_col}\"")); + } + kwargs.push("related_name=\"+\"".into()); + if nullable { + kwargs.push("null=True".into()); + kwargs.push("blank=True".into()); + } + + let kwargs_str = kwargs.join(", "); + lines.push(format!( + " {field_name} = models.ForeignKey({kwargs_str})" + )); +} + +/// Returns (field_name, Option). +/// If col_name ends with `_id`, strip it — Django automatically appends `_id`. +/// Otherwise, emit db_column explicitly so Django uses the raw column name. +/// Either way, `field_name` is sanitized into a valid Python identifier; if +/// that sanitization (or the `_id` strip) changes anything, `db_column` is +/// set to the original column name so the DB mapping isn't lost. +fn fk_field_name(col_name: &str) -> (String, Option) { + if let Some(base) = col_name.strip_suffix("_id") { + let sanitized = sanitize_identifier(base, IdentifierStart::Underscore); + if sanitized == base { + (sanitized, None) + } else { + (sanitized, Some(col_name.to_string())) + } + } else { + ( + sanitize_identifier(col_name, IdentifierStart::Underscore), + Some(col_name.to_string()), + ) + } +} + +fn assemble_with_imports(used: &UsedImports, parts: &[String]) -> String { + let mut lines: Vec = Vec::new(); + + lines.push("from __future__ import annotations".into()); + lines.push(String::new()); + + if used.needs_timezone { + lines.push("from django.utils import timezone".into()); + } + if used.needs_uuid_default { + lines.push("import uuid".into()); + } + + lines.push("from django.db import models".into()); + lines.push(String::new()); + lines.push(String::new()); + + lines.push(parts.join("\n")); + lines.join("\n") +} + +pub(super) 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_upper_snake_case(s: &str) -> String { + let mut result = String::new(); + let chars: Vec = s.chars().collect(); + for (i, &c) in chars.iter().enumerate() { + if c == '-' || c == ' ' { + if !result.ends_with('_') { + result.push('_'); + } + } else if c == '_' { + result.push('_'); + } else if c.is_uppercase() && i > 0 && !result.ends_with('_') { + // Only split on camelCase transitions (lowercase/digit → uppercase). + // Adjacent uppercase letters (e.g. "ERROR") are not split. + let prev = chars[i - 1]; + if prev.is_lowercase() || prev.is_ascii_digit() { + result.push('_'); + } + result.push(c); + } else { + result.push(c.to_ascii_uppercase()); + } + } + // Python identifiers cannot start with a digit + if result.starts_with(|c: char| c.is_ascii_digit()) { + result.insert(0, '_'); + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[rstest::rstest] + #[case("pending", "PENDING")] + #[case("in_progress", "IN_PROGRESS")] + #[case("inProgress", "IN_PROGRESS")] + #[case("ERROR_LEVEL", "ERROR_LEVEL")] + #[case("info-level", "INFO_LEVEL")] + #[case("1critical", "_1CRITICAL")] + fn test_to_upper_snake_case(#[case] input: &str, #[case] expected: &str) { + assert_eq!(to_upper_snake_case(input), expected); + } + + #[rstest::rstest] + #[case("author_id", "author", None)] + #[case("user_id", "user", None)] + #[case("parent", "parent", Some("parent"))] + #[case("ref", "ref", Some("ref"))] + fn test_fk_field_name( + #[case] col: &str, + #[case] expected_field: &str, + #[case] expected_db_col: Option<&str>, + ) { + let (field, db_col) = fk_field_name(col); + assert_eq!(field, expected_field); + assert_eq!(db_col.as_deref(), expected_db_col); + } + + #[test] + fn test_to_pascal_case_double_underscore() { + // Double underscore produces an empty word, triggering the None arm in to_pascal_case + assert_eq!(to_pascal_case("order__item"), "OrderItem"); + assert_eq!(to_pascal_case("_leading"), "Leading"); + assert_eq!(to_pascal_case("trailing_"), "Trailing"); + } + + #[test] + fn test_unique_name_double_collision_appends_incrementing_suffix() { + let mut used = HashSet::new(); + used.insert("tag".to_string()); + used.insert("tag_2".to_string()); + assert_eq!(unique_name("tag", &mut used), "tag_3"); + } +} diff --git a/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__basic_table.snap b/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__basic_table.snap new file mode 100644 index 00000000..47063e83 --- /dev/null +++ b/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__basic_table.snap @@ -0,0 +1,19 @@ +--- +source: crates/vespertide-exporter/src/django/mod.rs +assertion_line: 106 +expression: render_entity(&table).unwrap() +--- +from __future__ import annotations + +from django.db import models + + +class Users(models.Model): + """User accounts""" + + id = models.AutoField(primary_key=True) + email = models.CharField(max_length=255, unique=True) + name = models.TextField(null=True, blank=True) + + class Meta: + db_table = "users" diff --git a/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__composite_pk.snap b/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__composite_pk.snap new file mode 100644 index 00000000..b5332681 --- /dev/null +++ b/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__composite_pk.snap @@ -0,0 +1,18 @@ +--- +source: crates/vespertide-exporter/src/django/mod.rs +assertion_line: 238 +expression: render_entity(&table).unwrap() +--- +from __future__ import annotations + +from django.db import models + + +class OrderItems(models.Model): + pk = models.CompositePrimaryKey("order_id", "product_id") + order_id = models.IntegerField() + product_id = models.IntegerField() + quantity = models.IntegerField() + + class Meta: + db_table = "order_items" diff --git a/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__indexes_and_composite_unique.snap b/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__indexes_and_composite_unique.snap new file mode 100644 index 00000000..b98c8390 --- /dev/null +++ b/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__indexes_and_composite_unique.snap @@ -0,0 +1,24 @@ +--- +source: crates/vespertide-exporter/src/django/mod.rs +assertion_line: 252 +expression: render_entity(&table).unwrap() +--- +from __future__ import annotations + +from django.db import models + + +class Articles(models.Model): + id = models.AutoField(primary_key=True) + slug = models.CharField(max_length=200) + author_id = models.IntegerField() + created_at = models.DateTimeField() + + class Meta: + db_table = "articles" + indexes = [ + models.Index(fields=["created_at"], name="ix_articles__created_at"), + ] + constraints = [ + models.UniqueConstraint(fields=["slug", "author_id"], name="uq_articles__slug_author"), + ] diff --git a/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__server_default_timezone.snap b/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__server_default_timezone.snap new file mode 100644 index 00000000..2cc3d147 --- /dev/null +++ b/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__server_default_timezone.snap @@ -0,0 +1,18 @@ +--- +source: crates/vespertide-exporter/src/django/mod.rs +assertion_line: 286 +expression: result +--- +from __future__ import annotations + +from django.utils import timezone +from django.db import models + + +class Events(models.Model): + id = models.AutoField(primary_key=True) + created_at = models.DateTimeField(default=timezone.now) + count = models.IntegerField(default=0) + + class Meta: + db_table = "events" diff --git a/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__table_with_fk.snap b/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__table_with_fk.snap new file mode 100644 index 00000000..9b1c8e90 --- /dev/null +++ b/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__table_with_fk.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/django/mod.rs +assertion_line: 128 +expression: render_entity(&table).unwrap() +--- +from __future__ import annotations + +from django.db import models + + +class Posts(models.Model): + id = models.AutoField(primary_key=True) + author = models.ForeignKey("Users", on_delete=models.CASCADE, related_name="+") + title = models.TextField() + + class Meta: + db_table = "posts" diff --git a/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__table_with_integer_enum.snap b/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__table_with_integer_enum.snap new file mode 100644 index 00000000..df5d2323 --- /dev/null +++ b/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__table_with_integer_enum.snap @@ -0,0 +1,21 @@ +--- +source: crates/vespertide-exporter/src/django/mod.rs +assertion_line: 175 +expression: render_entity(&table).unwrap() +--- +from __future__ import annotations + +from django.db import models + + +class PriorityLevel(models.IntegerChoices): + LOW = 0, "low" + MEDIUM = 10, "medium" + HIGH = 20, "high" + +class Tasks(models.Model): + id = models.IntegerField(primary_key=True) + priority = models.IntegerField(choices=PriorityLevel.choices) + + class Meta: + db_table = "tasks" diff --git a/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__table_with_string_enum.snap b/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__table_with_string_enum.snap new file mode 100644 index 00000000..2b208135 --- /dev/null +++ b/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__table_with_string_enum.snap @@ -0,0 +1,21 @@ +--- +source: crates/vespertide-exporter/src/django/mod.rs +assertion_line: 157 +expression: render_entity(&table).unwrap() +--- +from __future__ import annotations + +from django.db import models + + +class OrderStatus(models.TextChoices): + PENDING = "pending", "pending" + SHIPPED = "shipped", "shipped" + DELIVERED = "delivered", "delivered" + +class Orders(models.Model): + id = models.AutoField(primary_key=True) + status = models.CharField(max_length=9, choices=OrderStatus.choices, default="pending") + + class Meta: + db_table = "orders" diff --git a/crates/vespertide-exporter/src/django/types.rs b/crates/vespertide-exporter/src/django/types.rs new file mode 100644 index 00000000..913e97ab --- /dev/null +++ b/crates/vespertide-exporter/src/django/types.rs @@ -0,0 +1,200 @@ +use vespertide_core::DefaultValue; +use vespertide_core::schema::column::{ + ColumnType, ComplexColumnType, EnumValues, SimpleColumnKind, SimpleColumnType, +}; + +#[derive(Default)] +pub(super) struct UsedImports { + pub(super) needs_timezone: bool, + pub(super) needs_uuid_default: bool, +} + +pub(super) fn django_field_type( + col_type: &ColumnType, + is_pk: bool, + auto_increment: bool, +) -> &'static str { + match col_type { + ColumnType::Simple(ty) => match SimpleColumnKind::from(*ty) { + SimpleColumnKind::SmallInt => { + if is_pk && auto_increment { + "models.SmallAutoField" + } else { + "models.SmallIntegerField" + } + } + SimpleColumnKind::Integer => { + if is_pk && auto_increment { + "models.AutoField" + } else { + "models.IntegerField" + } + } + SimpleColumnKind::BigInt => { + if is_pk && auto_increment { + "models.BigAutoField" + } else { + "models.BigIntegerField" + } + } + SimpleColumnKind::Real | SimpleColumnKind::DoublePrecision => "models.FloatField", + SimpleColumnKind::Text | SimpleColumnKind::Xml => "models.TextField", + SimpleColumnKind::Boolean => "models.BooleanField", + SimpleColumnKind::Date => "models.DateField", + SimpleColumnKind::Time => "models.TimeField", + SimpleColumnKind::Timestamp | SimpleColumnKind::Timestamptz => "models.DateTimeField", + SimpleColumnKind::Interval => "models.DurationField", + SimpleColumnKind::Bytea => "models.BinaryField", + SimpleColumnKind::Uuid => "models.UUIDField", + SimpleColumnKind::Json => "models.JSONField", + SimpleColumnKind::Inet | SimpleColumnKind::Cidr => "models.GenericIPAddressField", + SimpleColumnKind::Macaddr => "models.CharField", + }, + ColumnType::Complex(ty) => match ty { + ComplexColumnType::Varchar { .. } | ComplexColumnType::Char { .. } => { + "models.CharField" + } + ComplexColumnType::Numeric { .. } => "models.DecimalField", + ComplexColumnType::Custom { .. } => "models.TextField", + ComplexColumnType::Enum { values, .. } => match values { + EnumValues::String(_) => "models.CharField", + EnumValues::Integer(_) => "models.IntegerField", + }, + // `#[non_exhaustive]` future-variant guard; unreachable today. + #[cfg(not(tarpaulin_include))] + _ => { + unreachable!("ComplexColumnType is #[non_exhaustive]; all variants matched") + } + }, + } +} + +#[expect( + clippy::too_many_arguments, + reason = "all params are independent field-kwarg inputs; a context struct would add noise without reducing coupling" +)] +pub(super) fn build_field_kwargs( + col_type: &ColumnType, + is_pk: bool, + is_unique: bool, + nullable: bool, + default: Option<&DefaultValue>, + enum_class_name: Option<&str>, + db_column: Option<&str>, + used: &mut UsedImports, +) -> Vec { + let mut kwargs: Vec = Vec::new(); + + if let Some(db_col) = db_column { + kwargs.push(format!("db_column=\"{db_col}\"")); + } + + // Size / precision kwargs + match col_type { + ColumnType::Complex( + ComplexColumnType::Varchar { length } | ComplexColumnType::Char { length }, + ) => { + kwargs.push(format!("max_length={length}")); + } + ColumnType::Simple(SimpleColumnType::Macaddr) => { + kwargs.push("max_length=17".into()); + } + ColumnType::Complex(ComplexColumnType::Numeric { precision, scale }) => { + kwargs.push(format!("max_digits={precision}")); + kwargs.push(format!("decimal_places={scale}")); + } + ColumnType::Complex(ComplexColumnType::Enum { values, .. }) => { + if let Some(class) = enum_class_name { + if let EnumValues::String(vals) = values { + let mut max_len = 1; + for v in vals { + if v.len() > max_len { + max_len = v.len(); + } + } + kwargs.push(format!("max_length={max_len}")); + } + kwargs.push(format!("choices={class}.choices")); + } + } + _ => {} + } + + for (cond, kwarg) in [ + (is_pk, "primary_key=True"), + (is_unique && !is_pk, "unique=True"), + ] { + if cond { + kwargs.push(kwarg.into()); + } + } + if nullable && !is_pk { + kwargs.push("null=True".into()); + kwargs.push("blank=True".into()); + } + if let Some(dv) = default + && let Some(expr) = build_default(col_type, &dv.to_sql(), used) + { + kwargs.push(format!("default={expr}")); + } + + kwargs +} + +pub(super) fn build_default( + col_type: &ColumnType, + sql: &str, + used: &mut UsedImports, +) -> Option { + if sql.contains('(') { + let up = sql.to_uppercase(); + let is_timestamp_col = matches!( + col_type, + ColumnType::Simple(SimpleColumnType::Timestamp | SimpleColumnType::Timestamptz) + ); + if is_timestamp_col && (up.contains("NOW") || up.contains("CURRENT_TIMESTAMP")) { + used.needs_timezone = true; + return Some("timezone.now".into()); + } + if matches!(col_type, ColumnType::Simple(SimpleColumnType::Uuid)) { + used.needs_uuid_default = true; + return Some("uuid.uuid4".into()); + } + return None; + } + + let up = sql.to_uppercase(); + if up == "TRUE" { + return Some("True".into()); + } + if up == "FALSE" { + return Some("False".into()); + } + + if sql.starts_with('\'') && sql.ends_with('\'') && sql.len() >= 2 { + let inner = &sql[1..sql.len() - 1]; + return Some(format!("\"{}\"", inner.replace('"', "\\\""))); + } + + // A bare numeric literal (e.g. "0", "-1.5") is valid Python as-is. Any + // other bare, unquoted token is an unresolvable DB-level constant/ + // expression (e.g. a named SQL constant) — emitting it verbatim would + // produce an undefined-name reference in the generated Python, so omit + // the default entirely rather than guess. + if sql.parse::().is_ok() { + return Some(sql.into()); + } + + None +} + +pub(super) fn reference_action_str(action: &vespertide_core::ReferenceAction) -> &'static str { + use vespertide_core::ReferenceActionKind; + match ReferenceActionKind::from(action) { + ReferenceActionKind::Cascade => "models.CASCADE", + ReferenceActionKind::Restrict => "models.RESTRICT", + ReferenceActionKind::SetNull => "models.SET_NULL", + ReferenceActionKind::SetDefault => "models.SET_DEFAULT", + ReferenceActionKind::NoAction => "models.DO_NOTHING", + } +} From f653a2c7a9c5936f2fa1db69b4da05c3c9bd0f12 Mon Sep 17 00:00:00 2001 From: JaeHyunAn <98042706+yyuneu@users.noreply.github.com> Date: Sun, 20 Sep 2026 15:39:18 +0900 Subject: [PATCH 08/12] =?UTF-8?q?refactor(exporter):=20=ED=8C=8C=EC=9D=B4?= =?UTF-8?q?=EC=8D=AC=20=ED=82=A4=EC=9B=8C=EB=93=9C=C2=B7enum=20=EB=A9=A4?= =?UTF-8?q?=EB=B2=84=20=ED=97=AC=ED=8D=BC=EC=99=80=20=EC=A0=95=EC=85=98=20?= =?UTF-8?q?=EC=8A=A4=EC=BA=94=EC=9D=84=20=EA=B3=B5=EC=9A=A9=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/constraint_scan.rs | 43 ++++++- .../vespertide-exporter/src/python_naming.rs | 4 +- .../src/seaorm/relations/reverse.rs | 121 +++--------------- .../src/sqlalchemy/render.rs | 6 +- .../src/sqlmodel/render.rs | 6 +- .../vespertide-exporter/src/utils/python.rs | 67 +++++++++- 6 files changed, 133 insertions(+), 114 deletions(-) diff --git a/crates/vespertide-exporter/src/constraint_scan.rs b/crates/vespertide-exporter/src/constraint_scan.rs index 1c5dc900..55900968 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, ReferenceAction, TableConstraint, TableDef}; +use vespertide_core::{ColumnName, ReferenceAction, TableConstraint, TableDef, TableName}; use vespertide_naming::{infer_relation_field_name, to_pascal_case}; /// Collect the column names from every single-column constraint that `extract` @@ -116,6 +116,47 @@ pub(crate) fn single_column_fk_details( map } +/// The tables `junction` links `current` to, when `junction` is a many-to-many +/// junction: a composite primary key (`junction_pk`, two or more columns), two +/// or more foreign keys whose columns all lie in that key, and one of them +/// pointing at `current`. The targets are the other keys' tables in constraint +/// order — empty when every key points back at `current`, which is a +/// self-relation rather than a link. `None` when `junction` is not such a +/// table. Callers decide whether a target outside their schema counts. +pub(crate) fn junction_targets<'a>( + current: &TableDef, + junction: &'a TableDef, + junction_pk: &HashSet<&str>, +) -> Option> { + if junction_pk.len() < 2 { + return None; + } + let fks: Vec<(&[ColumnName], &TableName)> = junction + .constraints + .iter() + .filter_map(|c| match c { + TableConstraint::ForeignKey { + columns, ref_table, .. + } => Some((columns.as_slice(), ref_table)), + _ => None, + }) + .collect(); + if fks.len() < 2 + || !fks + .iter() + .all(|(cols, _)| cols.iter().all(|c| junction_pk.contains(c.as_str()))) + { + return None; + } + fks.iter().find(|(_, target)| **target == current.name)?; + Some( + fks.into_iter() + .filter(|(_, target)| **target != current.name) + .map(|(_, target)| target) + .collect(), + ) +} + /// Name segment a relation derives from its FK columns. /// /// Every column takes part — two composite FKs to the same target can share a diff --git a/crates/vespertide-exporter/src/python_naming.rs b/crates/vespertide-exporter/src/python_naming.rs index 92de8a03..ececcd59 100644 --- a/crates/vespertide-exporter/src/python_naming.rs +++ b/crates/vespertide-exporter/src/python_naming.rs @@ -1,6 +1,6 @@ //! 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 +//! each segment, keep the rest verbatim. SQLAlchemy, SQLModel, JPA, Django, +//! 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. //! diff --git a/crates/vespertide-exporter/src/seaorm/relations/reverse.rs b/crates/vespertide-exporter/src/seaorm/relations/reverse.rs index dd13b086..149b08a8 100644 --- a/crates/vespertide-exporter/src/seaorm/relations/reverse.rs +++ b/crates/vespertide-exporter/src/seaorm/relations/reverse.rs @@ -6,7 +6,7 @@ use std::collections::{BTreeMap, HashMap, HashSet}; -use vespertide_core::{ColumnName, TableConstraint, TableDef, TableName}; +use vespertide_core::{TableConstraint, TableDef}; use vespertide_naming::seaorm_module_name; use super::super::imports::{ @@ -15,7 +15,7 @@ use super::super::imports::{ }; use super::super::render::primary_key_columns; use super::naming::{generate_relation_enum_name, pluralize, unique_relation_enum_name}; -use crate::constraint_scan::single_column_uniques; +use crate::constraint_scan::{junction_targets, single_column_uniques}; /// Information about a reverse relation to be generated. struct ReverseRelation { @@ -75,63 +75,21 @@ pub(super) fn collect_reverse_relation_targets( targets } -/// Collect target entities from a junction table for M2M relations. +/// Collect target entities from a junction table for M2M relations: the +/// junction itself, then every linked table `schema` knows. fn collect_many_to_many_targets( current_table: &TableDef, junction_table: &TableDef, junction_pk: &HashSet<&str>, schema: &[TableDef], ) -> Option> { - if junction_pk.len() < 2 { - return None; - } - - let fks: Vec<(&[ColumnName], &TableName)> = junction_table - .constraints - .iter() - .filter_map(|c| { - if let TableConstraint::ForeignKey { - columns, ref_table, .. - } = c - { - Some((columns.as_slice(), ref_table)) - } else { - None - } - }) - .collect(); - - if fks.len() < 2 { - return None; - } - - let all_fk_cols_in_pk = fks - .iter() - .all(|(cols, _)| cols.iter().all(|c| junction_pk.contains(c.as_str()))); - - if !all_fk_cols_in_pk { - return None; - } - - fks.iter() - .find(|(_, ref_table)| **ref_table == current_table.name)?; - - let mut targets = Vec::new(); - - // Junction table itself - targets.push(junction_table.name.to_string()); - - // Target tables via M2M - for (_, ref_table) in &fks { - if **ref_table == current_table.name { - continue; - } - let target_exists = schema.iter().any(|t| &t.name == *ref_table); - if target_exists { - targets.push(ref_table.to_string()); - } - } - + let m2m = junction_targets(current_table, junction_table, junction_pk)?; + let mut targets = vec![junction_table.name.to_string()]; + targets.extend( + m2m.into_iter() + .filter(|target| schema.iter().any(|t| &t.name == *target)) + .map(ToString::to_string), + ); Some(targets) } @@ -363,56 +321,15 @@ fn collect_many_to_many_relations( junction_pk: &HashSet<&str>, schema: &[TableDef], ) -> Option> { - // Junction table must have composite PK (2+ columns) - if junction_pk.len() < 2 { - return None; - } - - // Collect all FKs from the junction table - let fks: Vec<(&[ColumnName], &TableName)> = junction_table - .constraints - .iter() - .filter_map(|c| { - if let TableConstraint::ForeignKey { - columns, ref_table, .. - } = c - { - Some((columns.as_slice(), ref_table)) - } else { - None - } - }) - .collect(); - - // Must have at least 2 FKs to be a junction table - if fks.len() < 2 { + let m2m = junction_targets(current_table, junction_table, junction_pk)?; + // Every FK points back at the current table: a self-relation, not an M2M + // junction linking two distinct tables. + if m2m.is_empty() { return None; } - // Check if all FK columns are part of the PK (typical junction table pattern) - let all_fk_cols_in_pk = fks - .iter() - .all(|(cols, _)| cols.iter().all(|c| junction_pk.contains(c.as_str()))); - - if !all_fk_cols_in_pk { - return None; - } - - // Find which FK references the current table - fks.iter() - .find(|(_, ref_table)| **ref_table == current_table.name)?; - let mut relations = Vec::new(); - // All FKs point back at the current table ⇒ pure self-ref junction, not an - // M2M junction linking two distinct tables. - if fks - .iter() - .all(|(_, ref_table)| **ref_table == current_table.name) - { - return None; - } - // First, add has_many to the junction table itself (direct relation, not M2M) let junction_pascal = to_pascal_case(&junction_table.name); let junction_base = pluralize(&sanitize_field_name(&junction_table.name)); @@ -429,12 +346,8 @@ fn collect_many_to_many_relations( }); // Then add has_many with via for the target tables (M2M relations) - for (_columns, ref_table) in &fks { - if **ref_table == current_table.name { - continue; - } - - let target_exists = schema.iter().any(|t| &t.name == *ref_table); + for ref_table in m2m { + let target_exists = schema.iter().any(|t| &t.name == ref_table); if !target_exists { continue; } diff --git a/crates/vespertide-exporter/src/sqlalchemy/render.rs b/crates/vespertide-exporter/src/sqlalchemy/render.rs index 6eea1476..bf005efa 100644 --- a/crates/vespertide-exporter/src/sqlalchemy/render.rs +++ b/crates/vespertide-exporter/src/sqlalchemy/render.rs @@ -5,6 +5,7 @@ use crate::parallel_config::{ PYTHON_EXPORT_PAR_TABLE_MIN_LEN, SQLALCHEMY_EXPORT_PAR_TABLE_THRESHOLD, }; use crate::utils::common::{collect_composite_fks, join_qualified_refs, join_quoted, push_attr}; +use crate::utils::python::escape_python_keyword; use rayon::prelude::*; use vespertide_core::schema::column::{ColumnType, ComplexColumnType, EnumValues}; use vespertide_core::schema::constraint::TableConstraint; @@ -340,7 +341,10 @@ fn render_column( // buffer (see `push_attr`), so the positional name is spliced in at the // front instead of `Vec::insert(0, ..)`; output is byte-identical to // prepending the fragment and re-joining with ", ". - let attr_name = sanitize_identifier(col.name.as_str(), IdentifierStart::Underscore); + let attr_name = escape_python_keyword(sanitize_identifier( + col.name.as_str(), + IdentifierStart::Underscore, + )); if attr_name != col.name.as_str() { attrs.insert_str(0, &format!("\"{}\", ", col.name)); } diff --git a/crates/vespertide-exporter/src/sqlmodel/render.rs b/crates/vespertide-exporter/src/sqlmodel/render.rs index a58f6f61..827fd974 100644 --- a/crates/vespertide-exporter/src/sqlmodel/render.rs +++ b/crates/vespertide-exporter/src/sqlmodel/render.rs @@ -7,6 +7,7 @@ use crate::parallel_config::{ use crate::utils::common::{ CompositeFk, collect_composite_fks, join_qualified_refs, join_quoted, unquote, }; +use crate::utils::python::escape_python_keyword; use vespertide_core::schema::column::{ColumnType, ComplexColumnType, EnumValues}; use vespertide_core::schema::constraint::TableConstraint; use vespertide_core::{ColumnDef, TableDef}; @@ -417,7 +418,10 @@ pub(super) fn render_column( // Build field definition // Pydantic rejects a leading `_` on model fields, so the escape is a letter. // A renamed field no longer points at its column, so name it explicitly. - let field_name = sanitize_identifier(col.name.as_str(), IdentifierStart::Letter); + let field_name = escape_python_keyword(sanitize_identifier( + col.name.as_str(), + IdentifierStart::Letter, + )); if field_name != col.name.as_str() { field_args.push(format!("sa_column_kwargs={{\"name\": \"{}\"}}", col.name)); } diff --git a/crates/vespertide-exporter/src/utils/python.rs b/crates/vespertide-exporter/src/utils/python.rs index 03b7fa35..858bdf06 100644 --- a/crates/vespertide-exporter/src/utils/python.rs +++ b/crates/vespertide-exporter/src/utils/python.rs @@ -1,7 +1,6 @@ use vespertide_core::schema::column::{ ColumnType, ComplexColumnType, EnumValues, SimpleColumnType, }; - use vespertide_naming::{IdentifierStart, sanitize_identifier, to_screaming_snake_case}; use crate::python_naming::to_pascal_case; @@ -17,10 +16,7 @@ pub(crate) fn render_enum(lines: &mut Vec, name: &str, values: &EnumValu EnumValues::String(vals) => { lines.push(format!("class {class_name}(str, enum.Enum):")); for val in vals { - // Python accepts a leading `_` in a member name, so the - // digit escape is `_` rather than the letter Prisma needs. - let variant_name = - sanitize_identifier(&to_screaming_snake_case(val), IdentifierStart::Underscore); + let variant_name = enum_member_name(val); lines.push(format!(" {variant_name} = \"{val}\"")); } } @@ -33,6 +29,50 @@ pub(crate) fn render_enum(lines: &mut Vec, name: &str, values: &EnumValu } } +/// Python's hard keywords (`keyword.kwlist`, 3.12). Soft keywords (`match`, +/// `case`, `type`, `_`) stay valid identifiers and need no escape. +const PYTHON_KEYWORDS: [&str; 35] = [ + "False", "None", "True", "and", "as", "assert", "async", "await", "break", "class", "continue", + "def", "del", "elif", "else", "except", "finally", "for", "from", "global", "if", "import", + "in", "is", "lambda", "nonlocal", "not", "or", "pass", "raise", "return", "try", "while", + "with", "yield", +]; + +pub(crate) fn is_python_keyword(name: &str) -> bool { + PYTHON_KEYWORDS.contains(&name) +} + +/// PEP 8's escape for a name that is a Python keyword: a trailing `_`. The +/// callers already emit the database column name whenever the attribute +/// differs from it. +pub(crate) fn escape_python_keyword(mut name: String) -> String { + if is_python_keyword(&name) { + name.push('_'); + } + name +} + +/// Member name for a Python enum class: `SCREAMING_SNAKE_CASE` of the value. +/// Python accepts a leading `_` in a member name, so the digit escape is `_` +/// rather than the letter Prisma needs. +pub(crate) fn enum_member_name(value: &str) -> String { + unmangled(sanitize_identifier( + &to_screaming_snake_case(value), + IdentifierStart::Underscore, + )) +} + +/// Inside a class body Python rewrites a name led by `__` into +/// `_Class__name`: an enum member spelled that way is no member, and a class +/// spelled that way cannot be named from another class. One `_` stays. +pub(crate) fn unmangled(name: String) -> String { + let body = name.trim_start_matches('_'); + if name.len() - body.len() < 2 { + return name; + } + format!("_{body}") +} + /// Map a `ColumnType` to its Python type annotation string, shared verbatim by /// the SQLAlchemy and SQLModel backends (both produce identical /// `int`/`float`/`str`/`datetime`/`Decimal`/`Optional[...]`/enum-PascalCase @@ -83,3 +123,20 @@ pub(crate) fn column_type_to_python(col_type: &ColumnType, nullable: bool) -> St base.to_string() } } + +#[cfg(test)] +mod tests { + use rstest::rstest; + + use super::enum_member_name; + + #[rstest] + #[case::plain("pending", "PENDING")] + #[case::words("in progress", "IN_PROGRESS")] + #[case::digit_led("1st", "_1ST")] + #[case::one_leading_separator("-x", "_X")] + #[case::leading_run_python_would_mangle("--x", "_X")] + fn enum_values_become_member_names(#[case] value: &str, #[case] expected: &str) { + assert_eq!(enum_member_name(value), expected); + } +} From 250c14c1f8080ff36ebe0bd6596070129a6435a3 Mon Sep 17 00:00:00 2001 From: JaeHyunAn <98042706+yyuneu@users.noreply.github.com> Date: Sun, 20 Sep 2026 15:40:10 +0900 Subject: [PATCH 09/12] =?UTF-8?q?feat(config):=20django=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=84=B9=EC=85=98(appLabel)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/vespertide-config/src/config.rs | 31 ++++++++++++++ crates/vespertide-config/src/lib.rs | 59 +++++++++++++++++++++++++- schemas/config.schema.json | 18 ++++++++ 3 files changed, 107 insertions(+), 1 deletion(-) diff --git a/crates/vespertide-config/src/config.rs b/crates/vespertide-config/src/config.rs index 126c28c3..1cef00fe 100644 --- a/crates/vespertide-config/src/config.rs +++ b/crates/vespertide-config/src/config.rs @@ -78,6 +78,28 @@ impl SeaOrmConfig { } } +/// Django-specific export configuration. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct DjangoConfig { + /// Explicit `app_label` written into every generated model's `Meta` + /// class. Needed when generated models don't live inside a standard + /// Django app package layout, where Django would otherwise infer the + /// label from the containing package. `None` (default) omits + /// `app_label` and leaves Django's normal inference in place. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub app_label: Option, +} + +impl DjangoConfig { + /// Explicit `app_label` to emit in every model's `Meta` class, if set. + pub fn app_label(&self) -> Option<&str> { + self.app_label.as_deref() + } +} + /// Top-level vespertide configuration. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] @@ -100,6 +122,9 @@ pub struct VespertideConfig { /// SeaORM-specific export configuration. #[serde(default)] pub seaorm: SeaOrmConfig, + /// Django-specific export configuration. + #[serde(default)] + pub django: DjangoConfig, /// Prefix to add to all table names (including migration version table). /// Default: "" (no prefix) #[serde(default)] @@ -138,6 +163,7 @@ impl Default for VespertideConfig { migration_filename_pattern: default_migration_filename_pattern(), model_export_dir: default_model_export_dir(), seaorm: SeaOrmConfig::default(), + django: DjangoConfig::default(), prefix: String::new(), lock_timeout_ms: None, statement_timeout_ms: None, @@ -191,6 +217,11 @@ impl VespertideConfig { &self.seaorm } + /// Django-specific export configuration. + pub fn django(&self) -> &DjangoConfig { + &self.django + } + /// Prefix to add to all table names. pub fn prefix(&self) -> &str { &self.prefix diff --git a/crates/vespertide-config/src/lib.rs b/crates/vespertide-config/src/lib.rs index 57ebace6..72ef8093 100644 --- a/crates/vespertide-config/src/lib.rs +++ b/crates/vespertide-config/src/lib.rs @@ -7,7 +7,9 @@ pub mod config; pub mod file_format; pub mod name_case; -pub use config::{SeaOrmConfig, VespertideConfig, default_migration_filename_pattern}; +pub use config::{ + DjangoConfig, SeaOrmConfig, VespertideConfig, default_migration_filename_pattern, +}; pub use file_format::FileFormat; pub use name_case::NameCase; @@ -100,4 +102,59 @@ mod tests { let cfg: VespertideConfig = serde_json::from_str(json).unwrap(); assert_eq!(cfg.seaorm().extra_enum_derives(), &["MyDerive"]); } + + #[test] + fn django_config_default_has_no_app_label() { + let cfg = DjangoConfig::default(); + assert_eq!(cfg.app_label(), None); + } + + #[test] + fn django_config_accessor() { + let cfg = DjangoConfig { + app_label: Some("myapp".to_string()), + }; + assert_eq!(cfg.app_label(), Some("myapp")); + } + + #[test] + fn django_config_deserialize_with_defaults() { + let json = r"{}"; + let cfg: DjangoConfig = serde_json::from_str(json).unwrap(); + assert_eq!(cfg.app_label(), None); + } + + #[test] + fn django_config_deserialize_with_app_label() { + let json = r#"{"appLabel": "myapp"}"#; + let cfg: DjangoConfig = serde_json::from_str(json).unwrap(); + assert_eq!(cfg.app_label(), Some("myapp")); + } + + #[test] + fn django_config_app_label_absent_from_json_when_none() { + let cfg = DjangoConfig::default(); + assert_eq!(serde_json::to_value(&cfg).unwrap(), serde_json::json!({})); + } + + #[test] + fn vespertide_config_django_accessor() { + let cfg = VespertideConfig::default(); + assert_eq!(cfg.django().app_label(), None); + } + + #[test] + fn vespertide_config_deserialize_with_django() { + let json = r#"{ + "modelsDir": "models", + "migrationsDir": "migrations", + "tableNamingCase": "snake", + "columnNamingCase": "snake", + "django": { + "appLabel": "myapp" + } + }"#; + let cfg: VespertideConfig = serde_json::from_str(json).unwrap(); + assert_eq!(cfg.django().app_label(), Some("myapp")); + } } diff --git a/schemas/config.schema.json b/schemas/config.schema.json index fa5f2852..3975cc7c 100644 --- a/schemas/config.schema.json +++ b/schemas/config.schema.json @@ -7,6 +7,11 @@ "columnNamingCase": { "$ref": "#/$defs/NameCase" }, + "django": { + "description": "Django-specific export configuration.", + "$ref": "#/$defs/DjangoConfig", + "default": {} + }, "lockTimeoutMs": { "description": "Maximum time (milliseconds) to wait acquiring a lock during a runtime\nmigration before failing. When set, the `vespertide_migration!` macro\nemits a backend-appropriate session/connection timeout at the start of\nthe migration (`PostgreSQL` `lock_timeout`, `MySQL`\n`innodb_lock_wait_timeout`, `SQLite` `PRAGMA busy_timeout`). `None`\n(default) leaves backend defaults untouched. Absent from serialized\nJSON when `None` (wire-compatible).", "type": [ @@ -76,6 +81,19 @@ "columnNamingCase" ], "$defs": { + "DjangoConfig": { + "description": "Django-specific export configuration.", + "type": "object", + "properties": { + "appLabel": { + "description": "Explicit `app_label` written into every generated model's `Meta`\nclass. Needed when generated models don't live inside a standard\nDjango app package layout, where Django would otherwise infer the\nlabel from the containing package. `None` (default) omits\n`app_label` and leaves Django's normal inference in place.", + "type": [ + "string", + "null" + ] + } + } + }, "FileFormat": { "description": "Supported file formats for generated artifacts.", "type": "string", From c0eda2142ede08f205a8ca86ef27533d45c538aa Mon Sep 17 00:00:00 2001 From: JaeHyunAn <98042706+yyuneu@users.noreply.github.com> Date: Sun, 20 Sep 2026 15:40:18 +0900 Subject: [PATCH 10/12] =?UTF-8?q?feat(exporter):=20Django=20=EB=B0=B1?= =?UTF-8?q?=EC=97=94=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../benches/codegen_benchmarks.rs | 6 +- .../vespertide-exporter/src/django/enums.rs | 54 +- crates/vespertide-exporter/src/django/mod.rs | 893 ++---------------- .../vespertide-exporter/src/django/render.rs | 884 +++++++++-------- ...exporter__django__tests__composite_pk.snap | 18 - ...__tests__indexes_and_composite_unique.snap | 24 - ...jango__tests__server_default_timezone.snap | 18 - ...xporter__django__tests__table_with_fk.snap | 17 - ...django__tests__table_with_string_enum.snap | 21 - .../vespertide-exporter/src/django/types.rs | 102 +- crates/vespertide-exporter/src/enum_scan.rs | 8 +- crates/vespertide-exporter/src/lib.rs | 4 +- crates/vespertide-exporter/src/orm.rs | 14 +- crates/vespertide-exporter/src/scope_names.rs | 6 +- .../src/tests/fixtures/identifiers.rs | 22 + .../src/tests/fixtures/junctions.rs | 49 + .../src/tests/fixtures/mod.rs | 7 +- .../src/tests/fixtures/reference_actions.rs | 5 +- .../src/tests/fixtures/schemas.rs | 93 ++ crates/vespertide-exporter/src/tests/mod.rs | 36 +- ...ypes_snapshot@all_simple_types_Django.snap | 33 + ...le_pk_snapshot@basic_single_pk_Django.snap | 16 + ...@basic_table_with_description_Django.snap} | 12 +- ...ns_snapshot@binding_collisions_Django.snap | 39 + ...x_types_snapshot@complex_types_Django.snap | 19 + ...snapshot@composite_constraints_Django.snap | 24 + ...snapshot@composite_fk_relation_Django.snap | 28 + ...index_snapshot@composite_index_Django.snap | 20 + ...osite_pk_snapshot@composite_pk_Django.snap | 17 + ...snapshot@composite_primary_key_Django.snap | 18 + ...ot@composite_unique_constraint_Django.snap | 20 + ...ique_snapshot@composite_unique_Django.snap | 20 + ...ts__defaults_snapshot@defaults_Django.snap | 18 + ...snapshot@enum_multiple_columns_Django.snap | 27 + ...enum_name_shared_across_tables_Django.snap | 32 + ...um_shared_snapshot@enum_shared_Django.snap | 22 + ...s_snapshot@enum_special_values_Django.snap | 22 + ...ult_snapshot@enum_with_default_Django.snap | 23 + ...snapshot@false_boolean_default_Django.snap | 16 + ...k_names_collide_after_id_strip_Django.snap | 25 + ...ith_comment_and_auto_increment_Django.snap | 17 + ...__inline_pk_snapshot@inline_pk_Django.snap | 17 + ...integer_enum_all_variant_types_Django.snap | 22 + ...shot@integer_enum_with_default_Django.snap | 20 + ...eger_enum_with_variant_default_Django.snap | 20 + ..._default_snapshot@json_default_Django.snap | 16 + ...ype_snapshot@jsonb_custom_type_Django.snap | 18 + ...ot@junction_over_composite_key_Django.snap | 35 + ...unction_over_composite_key_Drizzle_pg.snap | 37 + ...shot@junction_over_composite_key_Gorm.snap | 30 + ...pshot@junction_over_composite_key_Jpa.snap | 58 ++ ...ot@junction_over_composite_key_Prisma.snap | 30 + ...ot@junction_over_composite_key_SeaOrm.snap | 62 ++ ...unction_over_composite_key_SqlAlchemy.snap | 32 + ...@junction_over_composite_key_SqlModel.snap | 32 + ...iption_snapshot@no_description_Django.snap | 15 + ...dentifier_names_in_constraints_Django.snap | 25 + ..._snapshot@non_identifier_names_Django.snap | 18 + ...@non_identifier_relation_names_Django.snap | 25 + ...umns_snapshot@nullable_columns_Django.snap | 17 + ...le_enum_snapshot@nullable_enum_Django.snap | 20 + ...snapshot@numeric_default_value_Django.snap | 16 + ...er_snapshot@pk_and_fk_together_Django.snap | 26 + ...snapshot@python_reserved_names_Django.snap | 22 + ...shot@python_reserved_names_Drizzle_pg.snap | 20 + ...s_snapshot@python_reserved_names_Gorm.snap | 19 + ...es_snapshot@python_reserved_names_Jpa.snap | 39 + ...snapshot@python_reserved_names_Prisma.snap | 17 + ...snapshot@python_reserved_names_SeaOrm.snap | 26 + ...shot@python_reserved_names_SqlAlchemy.snap | 22 + ...apshot@python_reserved_names_SqlModel.snap | 21 + ...ons_snapshot@reference_actions_Django.snap | 32 + ..._snapshot@relation_field_names_Django.snap | 54 ++ ...@relation_name_taken_by_column_Django.snap | 23 + ...site_and_single_fk_same_target_Django.snap | 20 + ..._snapshots@composite_fk_parent_Django.snap | 17 + ...apshots@dual_reverse_relations_Django.snap | 15 + ...snapshots@many_to_many_article_Django.snap | 16 + ...ts@many_to_many_missing_target_Django.snap | 15 + ...any_to_many_multiple_junctions_Django.snap | 17 + ...ts@many_to_many_reserved_names_Django.snap | 18 + ...any_to_many_reserved_names_Drizzle_pg.snap | 13 + ...hots@many_to_many_reserved_names_Gorm.snap | 14 + ...shots@many_to_many_reserved_names_Jpa.snap | 20 + ...ts@many_to_many_reserved_names_Prisma.snap | 12 + ...ts@many_to_many_reserved_names_SeaOrm.snap | 25 + ...any_to_many_reserved_names_SqlAlchemy.snap | 16 + ...@many_to_many_reserved_names_SqlModel.snap | 15 + ...pshots@many_to_many_uninvolved_Django.snap | 15 + ...ts@many_to_many_uninvolved_Drizzle_pg.snap | 12 + ...napshots@many_to_many_uninvolved_Gorm.snap | 13 + ...snapshots@many_to_many_uninvolved_Jpa.snap | 17 + ...pshots@many_to_many_uninvolved_Prisma.snap | 11 + ...pshots@many_to_many_uninvolved_SeaOrm.snap | 56 ++ ...ts@many_to_many_uninvolved_SqlAlchemy.snap | 15 + ...hots@many_to_many_uninvolved_SqlModel.snap | 14 + ...ma_snapshots@many_to_many_user_Django.snap | 16 + ...apshots@multiple_fk_same_table_Django.snap | 17 + ...ots@multiple_has_one_relations_Django.snap | 15 + ...ots@multiple_reverse_relations_Django.snap | 15 + ..._junction_fk_not_in_pk_another_Django.snap | 15 + ...ot_junction_fk_not_in_pk_other_Django.snap | 15 + ...apshots@not_junction_single_pk_Django.snap | 15 + ...@one_to_one_shared_primary_key_Django.snap | 15 + ...ma_snapshots@one_to_one_source_Django.snap | 16 + ...shots@triple_reverse_relations_Django.snap | 15 + ...h_schema_snapshots@username_fk_Django.snap | 16 + ...shot@reserved_word_identifiers_Django.snap | 17 + ...k_snapshot@self_referencing_fk_Django.snap | 16 + ...ult_snapshot@semicolon_default_Django.snap | 17 + ...erver_default_and_true_boolean_Django.snap | 20 + ...aults_snapshot@server_defaults_Django.snap | 19 + ...@small_multi_schema_sequential_Django.snap | 25 + ...efault_snapshot@string_default_Django.snap | 16 + ...vel_pk_snapshot@table_level_pk_Django.snap | 17 + ...heck_snapshot@table_with_check_Django.snap | 16 + ...apshot@table_with_composite_fk_Django.snap | 19 + ..._enum_snapshot@table_with_enum_Django.snap | 21 + ...with_fk_snapshot@table_with_fk_Django.snap | 17 + ...es_snapshot@table_with_indexes_Django.snap | 21 + ...pshot@table_with_integer_enum_Django.snap} | 12 +- ...ed_snapshot@unique_and_indexed_Django.snap | 22 + ...pshot@unknown_constant_default_Django.snap | 16 + ...pshot@unknown_function_default_Django.snap | 16 + ...apshot@unnamed_composite_index_Django.snap | 20 + ...pshot@unnamed_composite_unique_Django.snap | 20 + ...pshot@unnamed_index_and_unique_Django.snap | 23 + .../vespertide-exporter/src/utils/common.rs | 8 +- .../tests/parallel_consolidated.rs | 2 + 129 files changed, 3160 insertions(+), 1411 deletions(-) delete mode 100644 crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__composite_pk.snap delete mode 100644 crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__indexes_and_composite_unique.snap delete mode 100644 crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__server_default_timezone.snap delete mode 100644 crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__table_with_fk.snap delete mode 100644 crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__table_with_string_enum.snap create mode 100644 crates/vespertide-exporter/src/tests/fixtures/junctions.rs create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__all_simple_types_snapshot@all_simple_types_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_single_pk_snapshot@basic_single_pk_Django.snap rename crates/vespertide-exporter/src/{django/snapshots/vespertide_exporter__django__tests__basic_table.snap => tests/snapshots/vespertide_exporter__tests__basic_table_with_description_snapshot@basic_table_with_description_Django.snap} (53%) create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__binding_collisions_snapshot@binding_collisions_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__complex_types_snapshot@complex_types_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_constraints_snapshot@composite_constraints_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_fk_relation_snapshot@composite_fk_relation_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_index_snapshot@composite_index_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_pk_snapshot@composite_pk_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_primary_key_snapshot@composite_primary_key_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_constraint_snapshot@composite_unique_constraint_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_snapshot@composite_unique_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__defaults_snapshot@defaults_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_multiple_columns_snapshot@enum_multiple_columns_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_shared_snapshot@enum_shared_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_special_values_snapshot@enum_special_values_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_with_default_snapshot@enum_with_default_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__false_boolean_default_snapshot@false_boolean_default_Django.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_Django.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_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__inline_pk_snapshot@inline_pk_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_all_variant_types_snapshot@integer_enum_all_variant_types_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_default_snapshot@integer_enum_with_default_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_variant_default_snapshot@integer_enum_with_variant_default_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__json_default_snapshot@json_default_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__jsonb_custom_type_snapshot@jsonb_custom_type_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_Drizzle_pg.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_Jpa.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_SeaOrm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_SqlAlchemy.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_SqlModel.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__no_description_snapshot@no_description_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_names_in_constraints_snapshot@non_identifier_names_in_constraints_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_names_snapshot@non_identifier_names_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_relation_names_snapshot@non_identifier_relation_names_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_columns_snapshot@nullable_columns_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_enum_snapshot@nullable_enum_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__numeric_default_value_snapshot@numeric_default_value_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__pk_and_fk_together_snapshot@pk_and_fk_together_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_Drizzle_pg.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_Jpa.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_SeaOrm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_SqlAlchemy.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_SqlModel.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_name_taken_by_column_snapshot@relation_name_taken_by_column_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@composite_and_single_fk_same_target_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@composite_fk_parent_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@dual_reverse_relations_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_article_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_missing_target_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_multiple_junctions_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_Drizzle_pg.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_Jpa.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_SeaOrm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_SqlAlchemy.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_SqlModel.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_Drizzle_pg.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_Gorm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_Jpa.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_SeaOrm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_SqlAlchemy.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_SqlModel.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_user_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_fk_same_table_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_has_one_relations_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_reverse_relations_Django.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_Django.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_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_single_pk_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@triple_reverse_relations_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@username_fk_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reserved_word_identifiers_snapshot@reserved_word_identifiers_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__self_referencing_fk_snapshot@self_referencing_fk_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_default_and_true_boolean_snapshot@server_default_and_true_boolean_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_defaults_snapshot@server_defaults_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__small_multi_schema_sequential_snapshot@small_multi_schema_sequential_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__string_default_snapshot@string_default_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_level_pk_snapshot@table_level_pk_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_check_snapshot@table_with_check_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_composite_fk_snapshot@table_with_composite_fk_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_enum_snapshot@table_with_enum_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_fk_snapshot@table_with_fk_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_indexes_snapshot@table_with_indexes_Django.snap rename crates/vespertide-exporter/src/{django/snapshots/vespertide_exporter__django__tests__table_with_integer_enum.snap => tests/snapshots/vespertide_exporter__tests__table_with_integer_enum_snapshot@table_with_integer_enum_Django.snap} (62%) create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unique_and_indexed_snapshot@unique_and_indexed_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_constant_default_snapshot@unknown_constant_default_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_function_default_snapshot@unknown_function_default_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_index_snapshot@unnamed_composite_index_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_unique_snapshot@unnamed_composite_unique_Django.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_index_and_unique_snapshot@unnamed_index_and_unique_Django.snap diff --git a/crates/vespertide-exporter/benches/codegen_benchmarks.rs b/crates/vespertide-exporter/benches/codegen_benchmarks.rs index 7c674cf7..99002c54 100644 --- a/crates/vespertide-exporter/benches/codegen_benchmarks.rs +++ b/crates/vespertide-exporter/benches/codegen_benchmarks.rs @@ -9,7 +9,7 @@ use vespertide_core::{ }; use vespertide_exporter::{Orm, render_entity_with_schema}; -const ALL_ORMS: [Orm; 7] = [ +const ALL_ORMS: [Orm; 8] = [ Orm::SeaOrm, Orm::SqlAlchemy, Orm::SqlModel, @@ -17,14 +17,16 @@ const ALL_ORMS: [Orm; 7] = [ Orm::Prisma, Orm::Drizzle, Orm::Gorm, + Orm::Django, ]; -const ENUM_ORMS: [Orm; 6] = [ +const ENUM_ORMS: [Orm; 7] = [ Orm::SeaOrm, Orm::SqlAlchemy, Orm::SqlModel, Orm::Prisma, Orm::Drizzle, Orm::Gorm, + Orm::Django, ]; const FK_COLUMNS_PER_TABLE: usize = 20; diff --git a/crates/vespertide-exporter/src/django/enums.rs b/crates/vespertide-exporter/src/django/enums.rs index 85dae123..4b3fab26 100644 --- a/crates/vespertide-exporter/src/django/enums.rs +++ b/crates/vespertide-exporter/src/django/enums.rs @@ -1,25 +1,59 @@ +use std::collections::HashSet; + use vespertide_core::schema::column::EnumValues; -use super::render::to_upper_snake_case; +use crate::enum_scan::variant_names; +use crate::utils::common::{claim_binding, string_literal}; +use crate::utils::python::enum_member_name; +/// Members carry only their value. Django derives the human label from the +/// member name (`PENDING` -> "Pending"); passing the raw database value as an +/// explicit label would pin a worse one ("pending"). pub(super) fn render_enum(lines: &mut Vec, class_name: &str, values: &EnumValues) { + let members = member_names(values); match values { EnumValues::String(vals) => { lines.push(format!("class {class_name}(models.TextChoices):")); - for val in vals { - let const_name = to_upper_snake_case(val); - lines.push(format!(" {const_name} = \"{val}\", \"{val}\"")); + for (val, member) in vals.iter().zip(members) { + lines.push(format!(" {member} = {}", string_literal(val))); } } EnumValues::Integer(vals) => { lines.push(format!("class {class_name}(models.IntegerChoices):")); - for val in vals { - let const_name = to_upper_snake_case(&val.name); - lines.push(format!( - " {const_name} = {}, \"{}\"", - val.value, val.name - )); + for (val, member) in vals.iter().zip(members) { + lines.push(format!(" {member} = {}", val.value)); } } } } + +/// One member name per variant, in order. Distinct values can fold onto one +/// name (`in progress`, `in-progress`), and Python's `Enum` refuses to define +/// a member twice — at import, which takes the whole module down. +fn member_names(values: &EnumValues) -> Vec { + let mut taken = HashSet::new(); + variant_names(values) + .into_iter() + .map(|variant| claim_binding(enum_member_name(variant), &mut taken)) + .collect() +} + +#[cfg(test)] +mod tests { + use vespertide_core::schema::column::EnumValues; + + use super::member_names; + + #[test] + fn values_that_fold_onto_one_member_name_are_numbered() { + let values = EnumValues::String(vec![ + "in progress".into(), + "in-progress".into(), + "done".into(), + ]); + assert_eq!( + member_names(&values), + ["IN_PROGRESS", "IN_PROGRESS2", "DONE"] + ); + } +} diff --git a/crates/vespertide-exporter/src/django/mod.rs b/crates/vespertide-exporter/src/django/mod.rs index 5174f2d6..101ec3ea 100644 --- a/crates/vespertide-exporter/src/django/mod.rs +++ b/crates/vespertide-exporter/src/django/mod.rs @@ -6,10 +6,7 @@ use crate::orm::OrmExporter; use vespertide_config::DjangoConfig; use vespertide_core::TableDef; -pub use render::{ - export, export_with_config, render_entity, render_entity_with_schema, - render_entity_with_schema_and_config, -}; +pub use render::{export, export_with_config, render_entity, render_entity_with_schema}; pub struct DjangoExporter; @@ -31,7 +28,7 @@ impl OrmExporter for DjangoExporter { /// (currently an optional `app_label` written into every model's `Meta` /// class). Mirrors `seaorm::SeaOrmExporterWithConfig`. pub struct DjangoExporterWithConfig<'a> { - pub config: &'a DjangoConfig, + config: &'a DjangoConfig, } impl<'a> DjangoExporterWithConfig<'a> { @@ -39,854 +36,108 @@ impl<'a> DjangoExporterWithConfig<'a> { Self { config } } - pub fn render_entity_with_schema( - &self, - table: &TableDef, - schema: &[TableDef], - ) -> Result { - render_entity_with_schema_and_config(table, schema, self.config.app_label()) + /// [`export`] with the configured `app_label`. + pub fn export(&self, schema: &[TableDef]) -> Result { + export_with_config(schema, self.config.app_label()) } } -#[cfg(test)] -pub(crate) fn to_pascal_case_for_tests(s: &str) -> String { - render::to_pascal_case(s) -} - #[cfg(test)] mod tests { - use super::*; - use insta::assert_snapshot; use rstest::rstest; - use vespertide_core::schema::column::{EnumValues, SimpleColumnType}; - use vespertide_core::schema::constraint::TableConstraint; - use vespertide_core::{ - ColumnType, ComplexColumnType, DefaultValue, NumValue, ReferenceAction, TableDef, - }; - - fn col(name: &str, ty: ColumnType) -> vespertide_core::ColumnDef { - vespertide_core::ColumnDef::new(name, ty, false) - } - - fn nullable_col(name: &str, ty: ColumnType) -> vespertide_core::ColumnDef { - vespertide_core::ColumnDef::new(name, ty, true) - } - - fn auto_pk(columns: &[&str]) -> TableConstraint { - TableConstraint::PrimaryKey { - auto_increment: true, - columns: columns.iter().copied().map(Into::into).collect(), - strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), - } - } - - fn pk(columns: &[&str]) -> TableConstraint { - TableConstraint::PrimaryKey { - auto_increment: false, - columns: columns.iter().copied().map(Into::into).collect(), - strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), - } - } - - fn fk(col: &str, ref_table: &str, on_delete: Option) -> TableConstraint { - TableConstraint::ForeignKey { - name: None, - columns: vec![col.into()], - ref_table: ref_table.into(), - ref_columns: vec!["id".into()], - on_delete, - on_update: None, - orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), - } - } - - // ----------------------------------------------------------------------- - // Basic table with autoincrement PK + nullable field - // ----------------------------------------------------------------------- - - #[test] - fn test_basic_table() { - let table = TableDef { - name: "users".into(), - description: Some("User accounts".into()), - columns: vec![ - col("id", ColumnType::Simple(SimpleColumnType::Integer)), - col( - "email", - ColumnType::Complex(ComplexColumnType::Varchar { length: 255 }), - ), - nullable_col("name", ColumnType::Simple(SimpleColumnType::Text)), - ], - constraints: vec![ - auto_pk(&["id"]), - TableConstraint::Unique { - name: None, - columns: vec!["email".into()], - strategy: vespertide_core::UniqueConstraintStrategy::DeleteDuplicates { - keep: vespertide_core::KeepPolicy::First, - }, - }, - ], - }; - assert_snapshot!(render_entity(&table).unwrap()); - } - - // ----------------------------------------------------------------------- - // FK field: `_id` suffix stripping - // ----------------------------------------------------------------------- - - #[test] - fn test_table_with_fk() { - let table = TableDef { - name: "posts".into(), - description: None, - columns: vec![ - col("id", ColumnType::Simple(SimpleColumnType::Integer)), - col("author_id", ColumnType::Simple(SimpleColumnType::Integer)), - col("title", ColumnType::Simple(SimpleColumnType::Text)), - ], - constraints: vec![ - auto_pk(&["id"]), - fk("author_id", "users", Some(ReferenceAction::Cascade)), - ], - }; - assert_snapshot!(render_entity(&table).unwrap()); - } - - // ----------------------------------------------------------------------- - // TextChoices enum - // ----------------------------------------------------------------------- - - #[test] - fn test_table_with_string_enum() { - let table = TableDef { - name: "orders".into(), - description: None, - columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer)), { - let mut c = col( - "status", - ColumnType::Complex(ComplexColumnType::Enum { - name: "order_status".into(), - values: EnumValues::String(vec![ - "pending".into(), - "shipped".into(), - "delivered".into(), - ]), - }), - ); - c.default = Some(DefaultValue::String("'pending'".into())); - c - }], - constraints: vec![auto_pk(&["id"])], - }; - assert_snapshot!(render_entity(&table).unwrap()); - } - - // ----------------------------------------------------------------------- - // IntegerChoices enum - // ----------------------------------------------------------------------- - - #[test] - fn test_table_with_integer_enum() { - let table = TableDef { - name: "tasks".into(), - description: None, - columns: vec![ - col("id", ColumnType::Simple(SimpleColumnType::Integer)), - col( - "priority", - 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, - }, - ]), - }), - ), - ], - constraints: vec![pk(&["id"])], - }; - assert_snapshot!(render_entity(&table).unwrap()); - } - - // ----------------------------------------------------------------------- - // Composite PK - // ----------------------------------------------------------------------- - - #[test] - fn test_composite_pk() { - let table = TableDef { - name: "order_items".into(), - description: None, - columns: vec![ - col("order_id", ColumnType::Simple(SimpleColumnType::Integer)), - col("product_id", ColumnType::Simple(SimpleColumnType::Integer)), - col("quantity", ColumnType::Simple(SimpleColumnType::Integer)), - ], - constraints: vec![pk(&["order_id", "product_id"])], - }; - let result = render_entity(&table).unwrap(); - assert!( - result.contains("pk = models.CompositePrimaryKey(\"order_id\", \"product_id\")"), - "expected Django 5.2+ CompositePrimaryKey declaration, got:\n{result}" - ); - assert!( - !result.contains("primary_key=True"), - "individual composite-PK columns must not also carry primary_key=True, got:\n{result}" - ); - assert_snapshot!(result); - } - - #[test] - fn test_composite_pk_of_fk_columns_uses_attname_not_field_name() { - // Composite PK made of FK columns: CompositePrimaryKey must reference - // the Django attname ("{field}_id"), not the stripped field name - // ("article"/"user") used for the ForeignKey attribute itself. - let table = TableDef { - name: "article_user".into(), - description: None, - columns: vec![ - col("article_id", ColumnType::Simple(SimpleColumnType::Integer)), - col("user_id", ColumnType::Simple(SimpleColumnType::Integer)), - ], - constraints: vec![ - pk(&["article_id", "user_id"]), - fk("article_id", "articles", Some(ReferenceAction::Cascade)), - fk("user_id", "users", Some(ReferenceAction::Cascade)), - ], - }; - let result = render_entity(&table).unwrap(); - assert!( - result.contains("pk = models.CompositePrimaryKey(\"article_id\", \"user_id\")"), - "expected attname-based CompositePrimaryKey args, got:\n{result}" - ); - } + use vespertide_core::ReferenceAction; + use vespertide_core::schema::column::{ColumnType, SimpleColumnType}; - // ----------------------------------------------------------------------- - // Indexes and composite unique in Meta - // ----------------------------------------------------------------------- + use super::types::{UsedImports, build_default, django_field_type, on_delete_for}; - #[test] - fn test_indexes_and_composite_unique() { - let table = TableDef { - name: "articles".into(), - description: None, - columns: vec![ - col("id", ColumnType::Simple(SimpleColumnType::Integer)), - col( - "slug", - ColumnType::Complex(ComplexColumnType::Varchar { length: 200 }), - ), - col("author_id", ColumnType::Simple(SimpleColumnType::Integer)), - col( - "created_at", - ColumnType::Simple(SimpleColumnType::Timestamptz), - ), - ], - constraints: vec![ - auto_pk(&["id"]), - TableConstraint::Index { - name: Some("ix_articles__created_at".into()), - columns: vec!["created_at".into()], - }, - TableConstraint::Unique { - name: Some("uq_articles__slug_author".into()), - columns: vec!["slug".into(), "author_id".into()], - strategy: vespertide_core::UniqueConstraintStrategy::DeleteDuplicates { - keep: vespertide_core::KeepPolicy::First, - }, - }, - ], - }; - assert_snapshot!(render_entity(&table).unwrap()); + /// A SQL string default becomes the Python value it spells: the outer + /// quotes go, a doubled `''` is one quote, and what Python's literal + /// cannot hold is escaped. + #[rstest] + #[case::plain("'draft'", r#""draft""#)] + #[case::doubled_sql_quote("'it''s'", r#""it's""#)] + #[case::double_quote_inside(r#"'say "hi"'"#, r#""say \"hi\"""#)] + #[case::backslash(r"'a\b'", r#""a\\b""#)] + #[case::empty("''", r#""""#)] + fn string_defaults_become_python_literals(#[case] sql: &str, #[case] expected: &str) { + let text = ColumnType::Simple(SimpleColumnType::Text); + let default = build_default(&text, sql, &mut UsedImports::default()); + assert_eq!(default.as_deref(), Some(expected)); } - // ----------------------------------------------------------------------- - // server default (NOW()) → timezone.now - // ----------------------------------------------------------------------- - #[test] - fn test_server_default_timezone() { - let table = TableDef { - name: "events".into(), - description: None, - columns: vec![ - col("id", ColumnType::Simple(SimpleColumnType::Integer)), - { - let mut c = col( - "created_at", - ColumnType::Simple(SimpleColumnType::Timestamptz), - ); - c.default = Some(DefaultValue::String("NOW()".into())); - c - }, - { - let mut c = col("count", ColumnType::Simple(SimpleColumnType::Integer)); - c.default = Some(DefaultValue::Integer(0)); - c - }, - ], - constraints: vec![auto_pk(&["id"])], - }; - let result = render_entity(&table).unwrap(); - assert!(result.contains("from django.utils import timezone")); - assert!(result.contains("default=timezone.now")); - assert!(result.contains("default=0")); - assert_snapshot!(result); + fn a_json_default_is_left_to_the_database() { + let json = ColumnType::Simple(SimpleColumnType::Json); + let default = build_default(&json, r#"'{"a": 1}'"#, &mut UsedImports::default()); + assert_eq!(default, None); } - // ----------------------------------------------------------------------- - // Type coverage — all simple types - // ----------------------------------------------------------------------- - #[rstest] #[case::small_int(SimpleColumnType::SmallInt, "models.SmallIntegerField")] - #[case::bigint(SimpleColumnType::BigInt, "models.BigIntegerField")] + #[case::integer(SimpleColumnType::Integer, "models.IntegerField")] + #[case::big_int(SimpleColumnType::BigInt, "models.BigIntegerField")] #[case::real(SimpleColumnType::Real, "models.FloatField")] + #[case::double_precision(SimpleColumnType::DoublePrecision, "models.FloatField")] #[case::text(SimpleColumnType::Text, "models.TextField")] + #[case::xml(SimpleColumnType::Xml, "models.TextField")] #[case::boolean(SimpleColumnType::Boolean, "models.BooleanField")] #[case::date(SimpleColumnType::Date, "models.DateField")] #[case::time(SimpleColumnType::Time, "models.TimeField")] #[case::timestamp(SimpleColumnType::Timestamp, "models.DateTimeField")] + #[case::timestamptz(SimpleColumnType::Timestamptz, "models.DateTimeField")] + #[case::interval(SimpleColumnType::Interval, "models.DurationField")] + #[case::bytea(SimpleColumnType::Bytea, "models.BinaryField")] #[case::uuid(SimpleColumnType::Uuid, "models.UUIDField")] #[case::json(SimpleColumnType::Json, "models.JSONField")] - #[case::bytea(SimpleColumnType::Bytea, "models.BinaryField")] #[case::inet(SimpleColumnType::Inet, "models.GenericIPAddressField")] - #[case::interval(SimpleColumnType::Interval, "models.DurationField")] + #[case::cidr(SimpleColumnType::Cidr, "models.GenericIPAddressField")] #[case::macaddr(SimpleColumnType::Macaddr, "models.CharField")] - fn test_simple_type_mapping(#[case] ty: SimpleColumnType, #[case] expected: &str) { - let table = TableDef { - name: "t".into(), - description: None, - columns: vec![ - col("id", ColumnType::Simple(SimpleColumnType::Integer)), - col("val", ColumnType::Simple(ty)), - ], - constraints: vec![pk(&["id"])], - }; - let result = render_entity(&table).unwrap(); - assert!( - result.contains(expected), - "expected {expected} in:\n{result}" + fn simple_types_map_to_field_classes(#[case] ty: SimpleColumnType, #[case] expected: &str) { + assert_eq!( + django_field_type(&ColumnType::Simple(ty), false, false), + expected ); } #[rstest] - #[case::small_auto(SimpleColumnType::SmallInt, "models.SmallAutoField")] - #[case::big_auto(SimpleColumnType::BigInt, "models.BigAutoField")] - fn test_auto_pk_field_types(#[case] ty: SimpleColumnType, #[case] expected: &str) { - let table = TableDef { - name: "t".into(), - description: None, - columns: vec![col("id", ColumnType::Simple(ty))], - constraints: vec![auto_pk(&["id"])], - }; - let result = render_entity(&table).unwrap(); - assert!( - result.contains(expected), - "expected {expected} in:\n{result}" - ); - } - - #[test] - fn test_numeric_field() { - 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![auto_pk(&["id"])], - }; - let result = render_entity(&table).unwrap(); - assert!( - result.contains("models.DecimalField"), - "expected DecimalField" - ); - assert!(result.contains("max_digits=10"), "expected max_digits=10"); - assert!( - result.contains("decimal_places=2"), - "expected decimal_places=2" + #[case::small_int(SimpleColumnType::SmallInt, "models.SmallAutoField")] + #[case::integer(SimpleColumnType::Integer, "models.AutoField")] + #[case::big_int(SimpleColumnType::BigInt, "models.BigAutoField")] + fn auto_increment_primary_keys_map_to_auto_fields( + #[case] ty: SimpleColumnType, + #[case] expected: &str, + ) { + assert_eq!( + django_field_type(&ColumnType::Simple(ty), true, true), + expected ); } - #[test] - fn test_custom_type_field() { - let table = TableDef { - name: "docs".into(), - description: None, - columns: vec![ - col("id", ColumnType::Simple(SimpleColumnType::Integer)), - col( - "data", - ColumnType::Complex(ComplexColumnType::Custom { - custom_type: "JSONB".into(), - }), - ), - ], - constraints: vec![auto_pk(&["id"])], - }; - let result = render_entity(&table).unwrap(); - // Custom type → models.TextField (Django has no native JSONB) - assert!( - result.contains("data = models.TextField()"), - "expected Custom→TextField in:\n{result}" - ); - } - - #[test] - fn test_uuid_default() { - let mut id_col = col("id", ColumnType::Simple(SimpleColumnType::Uuid)); - id_col.default = Some(DefaultValue::String("gen_random_uuid()".into())); - let table = TableDef { - name: "sessions".into(), - description: None, - columns: vec![id_col], - constraints: vec![pk(&["id"])], - }; - let result = render_entity(&table).unwrap(); - assert!(result.contains("import uuid"), "expected uuid import"); - assert!( - result.contains("default=uuid.uuid4"), - "expected uuid4 callable" - ); - } - - #[test] - fn test_export_multi_table() { - let users = TableDef { - name: "users".into(), - description: None, - columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], - constraints: vec![auto_pk(&["id"])], - }; - let posts = TableDef { - name: "posts".into(), - description: None, - columns: vec![ - col("id", ColumnType::Simple(SimpleColumnType::Integer)), - col("author_id", ColumnType::Simple(SimpleColumnType::Integer)), - ], - constraints: vec![ - auto_pk(&["id"]), - fk("author_id", "users", Some(ReferenceAction::Cascade)), - ], - }; - let result = export(&[users, posts]).unwrap(); - assert!(result.contains("class Users(models.Model):")); - assert!(result.contains("class Posts(models.Model):")); - } - - #[test] - fn test_nullable_fk_with_db_column() { - // FK column without `_id` suffix → emits db_column kwarg - // Nullable FK → emits null=True, blank=True - let table = TableDef { - name: "comments".into(), - description: None, - columns: vec![ - col("id", ColumnType::Simple(SimpleColumnType::Integer)), - nullable_col("parent", ColumnType::Simple(SimpleColumnType::Integer)), - ], - constraints: vec![ - auto_pk(&["id"]), - fk("parent", "comments", Some(ReferenceAction::SetNull)), - ], - }; - let result = render_entity(&table).unwrap(); - assert!( - result.contains(r#"db_column="parent""#), - "expected db_column kwarg" - ); - assert!( - result.contains("null=True"), - "expected null=True for nullable FK" - ); - assert!( - result.contains("blank=True"), - "expected blank=True for nullable FK" - ); - } - - // ----------------------------------------------------------------------- - // build_default: Boolean false → "False" - // ----------------------------------------------------------------------- - - #[test] - fn test_bool_false_default() { - let mut flag = col("enabled", ColumnType::Simple(SimpleColumnType::Boolean)); - flag.default = Some(DefaultValue::Bool(false)); - let table = TableDef { - name: "settings".into(), - description: None, - columns: vec![ - col("id", ColumnType::Simple(SimpleColumnType::Integer)), - flag, - ], - constraints: vec![auto_pk(&["id"])], - }; - let result = render_entity(&table).unwrap(); - assert!(result.contains("default=False"), "expected default=False"); - } - - // ----------------------------------------------------------------------- - // build_default: Boolean true → "True" - // ----------------------------------------------------------------------- - - #[test] - fn test_bool_true_default() { - let mut flag = col("enabled", ColumnType::Simple(SimpleColumnType::Boolean)); - flag.default = Some(DefaultValue::Bool(true)); - let table = TableDef { - name: "settings".into(), - description: None, - columns: vec![ - col("id", ColumnType::Simple(SimpleColumnType::Integer)), - flag, - ], - constraints: vec![auto_pk(&["id"])], - }; - let result = render_entity(&table).unwrap(); - assert!(result.contains("default=True"), "expected default=True"); - } - - // ----------------------------------------------------------------------- - // build_default: functional default on non-Timestamp/UUID type → None (omitted) - // ----------------------------------------------------------------------- - - #[test] - fn test_functional_default_non_special() { - let mut seq_id = col("seq_id", ColumnType::Simple(SimpleColumnType::Integer)); - seq_id.default = Some(DefaultValue::String("nextval('my_seq')".into())); - let table = TableDef { - name: "items".into(), - description: None, - columns: vec![seq_id], - constraints: vec![pk(&["seq_id"])], - }; - let result = render_entity(&table).unwrap(); - assert!( - !result.contains("default="), - "functional default should be omitted" - ); - } - - // ----------------------------------------------------------------------- - // reference_action_str: Restrict, SetDefault, NoAction - // ----------------------------------------------------------------------- - #[rstest] - #[case(ReferenceAction::Restrict, "models.RESTRICT")] - #[case(ReferenceAction::SetDefault, "models.SET_DEFAULT")] - #[case(ReferenceAction::NoAction, "models.DO_NOTHING")] - fn test_fk_on_delete_actions(#[case] action: ReferenceAction, #[case] expected: &str) { - let table = TableDef { - name: "comments".into(), - description: None, - columns: vec![ - col("id", ColumnType::Simple(SimpleColumnType::Integer)), - col("post_id", ColumnType::Simple(SimpleColumnType::Integer)), - ], - constraints: vec![auto_pk(&["id"]), fk("post_id", "posts", Some(action))], - }; - let result = render_entity(&table).unwrap(); - assert!( - result.contains(expected), - "expected {expected} in:\n{result}" - ); - } - - // ----------------------------------------------------------------------- - // Column comment → emits "# ..." line before the field - // ----------------------------------------------------------------------- - - #[test] - fn test_column_comment() { - let mut c = col("name", ColumnType::Simple(SimpleColumnType::Text)); - c.comment = Some("The user's full name".into()); - let table = TableDef { - name: "users".into(), - description: None, - columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer)), c], - constraints: vec![auto_pk(&["id"])], - }; - let result = render_entity(&table).unwrap(); - assert!( - result.contains(" # The user's full name"), - "expected column comment in output" - ); - } - - // ----------------------------------------------------------------------- - // Unnamed index and unnamed composite unique in Meta - // ----------------------------------------------------------------------- - - #[test] - fn test_index_and_unique_no_name() { - let table = TableDef { - name: "entries".into(), - description: None, - columns: vec![ - col("id", ColumnType::Simple(SimpleColumnType::Integer)), - col( - "slug", - ColumnType::Complex(ComplexColumnType::Varchar { length: 100 }), - ), - col( - "tag", - ColumnType::Complex(ComplexColumnType::Varchar { length: 50 }), - ), - ], - constraints: vec![ - auto_pk(&["id"]), - TableConstraint::Index { - name: None, - columns: vec!["slug".into()], - }, - TableConstraint::Unique { - name: None, - columns: vec!["slug".into(), "tag".into()], - strategy: vespertide_core::UniqueConstraintStrategy::DeleteDuplicates { - keep: vespertide_core::KeepPolicy::First, - }, - }, - ], - }; - let result = render_entity(&table).unwrap(); - assert!( - result.contains("models.Index(fields=[\"slug\"]),"), - "expected unnamed Index" - ); - assert!( - result.contains("models.UniqueConstraint(fields=[\"slug\", \"tag\"]),"), - "expected unnamed UniqueConstraint" - ); - } - - // ----------------------------------------------------------------------- - // Many-to-many junction table recognition (render_entity_with_schema) - // ----------------------------------------------------------------------- - - fn users_table() -> TableDef { - TableDef { - name: "users".into(), - description: None, - columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], - constraints: vec![auto_pk(&["id"])], - } - } - - fn tags_table() -> TableDef { - TableDef { - name: "tags".into(), - description: None, - columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], - constraints: vec![auto_pk(&["id"])], - } - } - - fn junction_table( - name: &str, - left_col: &str, - left_ref: &str, - right_col: &str, - right_ref: &str, - ) -> TableDef { - TableDef { - name: name.into(), - description: None, - columns: vec![ - col(left_col, ColumnType::Simple(SimpleColumnType::Integer)), - col(right_col, ColumnType::Simple(SimpleColumnType::Integer)), - ], - constraints: vec![ - pk(&[left_col, right_col]), - fk(left_col, left_ref, None), - fk(right_col, right_ref, None), - ], - } - } - - #[test] - fn test_many_to_many_junction_table() { - let users = users_table(); - let tags = tags_table(); - let user_tags = junction_table("user_tags", "user_id", "users", "tag_id", "tags"); - let schema = vec![users.clone(), tags.clone(), user_tags.clone()]; - - let result = render_entity_with_schema(&users, &schema).unwrap(); - assert!( - result.contains( - "tags = models.ManyToManyField(\"Tags\", through=\"UserTags\", related_name=\"+\")" - ), - "expected ManyToManyField on users side, got:\n{result}" - ); - - let result = render_entity_with_schema(&tags, &schema).unwrap(); - assert!( - result.contains( - "users = models.ManyToManyField(\"Users\", through=\"UserTags\", related_name=\"+\")" - ), - "expected ManyToManyField on tags side, got:\n{result}" - ); - } - - #[test] - fn test_many_to_many_disambiguates_multiple_junctions_to_same_target() { - let users = users_table(); - let tags = tags_table(); - let user_tags = junction_table("user_tags", "user_id", "users", "tag_id", "tags"); - let user_favorite_tags = - junction_table("user_favorite_tags", "user_id", "users", "tag_id", "tags"); - let schema = vec![users.clone(), tags, user_tags, user_favorite_tags]; - - let result = render_entity_with_schema(&users, &schema).unwrap(); - assert!( - result.contains( - "tags_via_user_tags = models.ManyToManyField(\"Tags\", through=\"UserTags\"" - ), - "expected disambiguated field for user_tags junction, got:\n{result}" - ); - assert!( - result.contains( - "tags_via_user_favorite_tags = models.ManyToManyField(\"Tags\", through=\"UserFavoriteTags\"" - ), - "expected disambiguated field for user_favorite_tags junction, got:\n{result}" - ); - } - - #[test] - fn test_purely_self_referential_junction_is_skipped() { - // "friends" links users to users on both sides — not a two-sided M2M - // we can safely name, so no ManyToManyField should be emitted. - let users = users_table(); - let friends = junction_table("friends", "user_id", "users", "friend_id", "users"); - let schema = vec![users.clone(), friends]; - - let result = render_entity_with_schema(&users, &schema).unwrap(); - assert!( - !result.contains("ManyToManyField"), - "self-referential junction must not produce a guessed M2M field, got:\n{result}" - ); - } - - #[test] - fn test_junction_table_unrelated_to_current_table_is_ignored() { - // "order_tags" is a genuine junction (composite PK, 2 FKs both in the - // PK), but neither side references `users` at all — it links - // "orders" and "tags" together, so it must not produce any - // ManyToManyField on `users`. - let users = users_table(); - let tags = tags_table(); - let orders = TableDef { - name: "orders".into(), - description: None, - columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], - constraints: vec![auto_pk(&["id"])], - }; - let order_tags = junction_table("order_tags", "order_id", "orders", "tag_id", "tags"); - let schema = vec![users.clone(), tags, orders, order_tags]; - - let result = render_entity_with_schema(&users, &schema).unwrap(); - assert!( - !result.contains("ManyToManyField"), - "junction table unrelated to `users` must not produce a M2M field, got:\n{result}" - ); - } - - #[test] - fn test_export_multi_table_includes_many_to_many() { - let users = users_table(); - let tags = tags_table(); - let user_tags = junction_table("user_tags", "user_id", "users", "tag_id", "tags"); - let result = export(&[users, tags, user_tags]).unwrap(); - assert!( - result.contains("models.ManyToManyField(\"Tags\", through=\"UserTags\""), - "expected ManyToManyField in multi-table export, got:\n{result}" - ); - } - - // ----------------------------------------------------------------------- - // Composite FK: Django has no native multi-column FK field, so it must - // be surfaced as a comment instead of silently dropped. - // ----------------------------------------------------------------------- - - #[test] - fn test_composite_fk_emits_comment() { - let table = 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![ - pk(&["order_id", "region_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: None, - on_update: None, - orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), - }, - ], - }; - let result = render_entity(&table).unwrap(); - assert!( - result.contains( - "# composite foreign key: (order_id, region_id) -> order_regions(order_id, region_id)" - ), - "expected composite FK comment, got:\n{result}" - ); - } - - // ----------------------------------------------------------------------- - // DjangoExporterWithConfig: app_label reaches the Meta class - // ----------------------------------------------------------------------- - - #[test] - fn test_app_label_omitted_by_default() { - let table = users_table(); - let schema = vec![table.clone()]; - let config = DjangoConfig::default(); - let exporter = DjangoExporterWithConfig::new(&config); - let result = exporter.render_entity_with_schema(&table, &schema).unwrap(); - assert!( - !result.contains("app_label"), - "expected no app_label with default config, got:\n{result}" - ); - } - - #[test] - fn test_app_label_from_config_reaches_meta_class() { - let table = users_table(); - let schema = vec![table.clone()]; - let mut config = DjangoConfig::default(); - config.app_label = Some("myapp".to_string()); - let exporter = DjangoExporterWithConfig::new(&config); - let result = exporter.render_entity_with_schema(&table, &schema).unwrap(); - assert!( - result.contains(" app_label = \"myapp\""), - "expected app_label in Meta class, got:\n{result}" - ); + #[case::cascade(Some(ReferenceAction::Cascade), true, true, "models.CASCADE")] + #[case::restrict(Some(ReferenceAction::Restrict), true, true, "models.RESTRICT")] + #[case::set_null(Some(ReferenceAction::SetNull), true, true, "models.SET_NULL")] + #[case::set_default(Some(ReferenceAction::SetDefault), true, true, "models.SET_DEFAULT")] + #[case::no_action(Some(ReferenceAction::NoAction), true, true, "models.DO_NOTHING")] + #[case::no_action_given(None, true, true, "models.RESTRICT")] + #[case::set_null_on_a_field_that_is_not_null( + Some(ReferenceAction::SetNull), + true, + false, + "models.DO_NOTHING" + )] + #[case::set_default_without_a_default( + Some(ReferenceAction::SetDefault), + false, + true, + "models.DO_NOTHING" + )] + fn reference_actions_map_to_on_delete( + #[case] action: Option, + #[case] has_default: bool, + #[case] null: bool, + #[case] expected: &str, + ) { + assert_eq!(on_delete_for(action.as_ref(), has_default, null), expected); } } diff --git a/crates/vespertide-exporter/src/django/render.rs b/crates/vespertide-exporter/src/django/render.rs index c855ac33..69791a13 100644 --- a/crates/vespertide-exporter/src/django/render.rs +++ b/crates/vespertide-exporter/src/django/render.rs @@ -1,36 +1,37 @@ use std::collections::{HashMap, HashSet}; use super::enums::render_enum; -use super::types::{UsedImports, build_field_kwargs, django_field_type, reference_action_str}; -use crate::utils::python::collect_composite_fks; +use super::types::{ + UsedImports, build_default, build_field_kwargs, django_field_type, on_delete_for, +}; +use crate::constraint_scan::{ + FkDetails, junction_targets, primary_key, primary_key_columns, single_column_fk_details, + single_column_uniques, +}; +use crate::python_naming::to_pascal_case; +use crate::scope_names::{ScopeNames, scope_of}; +use crate::utils::common::{claim_binding, collect_composite_fks, string_literal}; +use crate::utils::python::{is_python_keyword, unmangled}; use vespertide_core::schema::column::{ColumnType, ComplexColumnType}; use vespertide_core::schema::constraint::TableConstraint; use vespertide_core::{ReferenceAction, TableDef}; -use vespertide_naming::{IdentifierStart, sanitize_identifier}; +use vespertide_naming::{ + IdentifierStart, build_index_name, build_unique_constraint_name, pluralize, sanitize_identifier, +}; pub fn render_entity(table: &TableDef) -> Result { let mut used = UsedImports::default(); - let body = render_entity_part(table, &mut used, &[], None); + let names = module_names(std::slice::from_ref(table)); + let body = render_entity_part(table, &[], &mut used, &names, None); Ok(assemble_with_imports(&used, &[body])) } /// Render a single table with full schema context so many-to-many junction /// tables can be recognized and exposed as `ManyToManyField(..., through=...)`. pub fn render_entity_with_schema(table: &TableDef, schema: &[TableDef]) -> Result { - render_entity_with_schema_and_config(table, schema, None) -} - -/// Same as [`render_entity_with_schema`], but with an optional `app_label` -/// (from `vespertide.json`'s `django` config) written into every model's -/// `Meta` class. -pub fn render_entity_with_schema_and_config( - table: &TableDef, - schema: &[TableDef], - app_label: Option<&str>, -) -> Result { let mut used = UsedImports::default(); - let m2m_fields = find_many_to_many_fields(table, schema); - let body = render_entity_part(table, &mut used, &m2m_fields, app_label); + let names = module_names(scope_of(table, schema)); + let body = render_entity_part(table, schema, &mut used, &names, None); Ok(assemble_with_imports(&used, &[body])) } @@ -38,178 +39,67 @@ pub fn export(schema: &[TableDef]) -> Result { export_with_config(schema, None) } -/// Same as [`export`], but with an optional `app_label` written into every -/// model's `Meta` class. +/// Same as [`export`], but with an optional `app_label` (from +/// `vespertide.json`'s `django` config) written into every model's `Meta` +/// class. pub fn export_with_config(schema: &[TableDef], app_label: Option<&str>) -> Result { let mut used = UsedImports::default(); + let names = module_names(schema); let parts: Vec = schema .iter() - .map(|t| { - let m2m_fields = find_many_to_many_fields(t, schema); - render_entity_part(t, &mut used, &m2m_fields, app_label) - }) + .map(|t| render_entity_part(t, schema, &mut used, &names, app_label)) .collect(); Ok(assemble_with_imports(&used, &parts)) } -/// Recognize many-to-many junction tables (composite PK, 2+ FKs, all FK -/// columns part of the PK) that reference `table`, and render the -/// corresponding `ManyToManyField` lines for the *other* side of each -/// junction. Purely self-referential junctions (every FK pointing back at -/// `table`) are skipped rather than guessed at. -fn find_many_to_many_fields(table: &TableDef, schema: &[TableDef]) -> Vec { - let mut matches: Vec<(String, String)> = Vec::new(); // (target_table, junction_table) - - for other in schema { - if other.name == table.name { +/// The other side of every many-to-many junction that links `table`: each +/// `(target, junction)` pair whose target `schema` also knows, in schema +/// order. Purely self-referential junctions yield no pairs, and neither does +/// a junction that reaches either end by a composite key: that key renders as +/// a comment, and a `through` model needs a real `ForeignKey` to both ends +/// (fields.E336) — nor can Django relate to the composite-key model such a +/// key points at (fields.E347). +fn many_to_many_targets<'a>(table: &TableDef, schema: &'a [TableDef]) -> Vec<(&'a str, &'a str)> { + let mut pairs = Vec::new(); + for junction in schema { + if junction.name == table.name { continue; } - - let other_pk: HashSet = other - .constraints - .iter() - .filter_map(|c| { - if let TableConstraint::PrimaryKey { columns, .. } = c { - Some( - columns - .iter() - .map(|c| c.as_str().to_owned()) - .collect::>(), - ) - } else { - None - } - }) - .flatten() - .collect(); - if other_pk.len() < 2 { + let junction_pk = primary_key_columns(&junction.constraints); + let Some(targets) = junction_targets(table, junction, &junction_pk) else { continue; - } - - let fks: Vec<(Vec, String)> = other - .constraints - .iter() - .filter_map(|c| { - if let TableConstraint::ForeignKey { - columns, ref_table, .. - } = c - { - Some(( - columns.iter().map(|c| c.as_str().to_owned()).collect(), - ref_table.as_str().to_owned(), - )) - } else { - None - } - }) + }; + let reached_by_foreign_key: HashSet<&str> = single_column_fk_details(&junction.constraints) + .values() + .filter(|fk| is_relatable(fk, schema)) + .map(|fk| fk.ref_table) .collect(); - if fks.len() < 2 { - continue; - } - - let all_fk_cols_in_pk = fks - .iter() - .all(|(cols, _)| cols.iter().all(|c| other_pk.contains(c.as_str()))); - if !all_fk_cols_in_pk { - continue; - } - - if !fks - .iter() - .any(|(_, ref_table)| ref_table.as_str() == table.name.as_str()) - { - continue; - } - if fks - .iter() - .all(|(_, ref_table)| ref_table.as_str() == table.name.as_str()) - { + if !reached_by_foreign_key.contains(table.name.as_str()) { continue; } - - for (_, ref_table) in &fks { - if ref_table.as_str() == table.name.as_str() { - continue; - } - if schema.iter().any(|t| t.name.as_str() == ref_table.as_str()) { - matches.push((ref_table.clone(), other.name.as_str().to_owned())); + for target in targets { + if reached_by_foreign_key.contains(target.as_str()) + && schema.iter().any(|t| t.name == *target) + { + pairs.push((target.as_str(), junction.name.as_str())); } } } - - let mut target_counts: HashMap = HashMap::new(); - for (target, _) in &matches { - *target_counts.entry(target.clone()).or_default() += 1; - } - - let mut used_names: HashSet = HashSet::new(); - matches - .iter() - .map(|(target, junction)| { - let base = pluralize(target); - let field_name = if target_counts.get(target).copied().unwrap_or(0) > 1 { - unique_name(&format!("{base}_via_{junction}"), &mut used_names) - } else { - unique_name(&base, &mut used_names) - }; - let target_class = sanitize_identifier(&to_pascal_case(target), IdentifierStart::Underscore); - let junction_class = - sanitize_identifier(&to_pascal_case(junction), IdentifierStart::Underscore); - format!( - " {field_name} = models.ManyToManyField(\"{target_class}\", through=\"{junction_class}\", related_name=\"+\")" - ) - }) - .collect() -} - -fn pluralize(name: &str) -> String { - if name.ends_with('s') { - name.to_string() - } else { - format!("{name}s") - } -} - -fn unique_name(base: &str, used: &mut HashSet) -> String { - if used.insert(base.to_string()) { - return base.to_string(); - } - let mut n = 2; - loop { - let candidate = format!("{base}_{n}"); - if used.insert(candidate.clone()) { - return candidate; - } - n += 1; - } + pairs } fn render_entity_part( table: &TableDef, + schema: &[TableDef], used: &mut UsedImports, - extra_fields: &[String], + names: &ScopeNames, app_label: Option<&str>, ) -> String { let mut lines: Vec = Vec::new(); + let m2m = many_to_many_targets(table, schema); // --- Constraint lookups --- - let pk_columns: HashSet = table - .constraints - .iter() - .filter_map(|c| { - if let TableConstraint::PrimaryKey { columns, .. } = c { - Some( - columns - .iter() - .map(|c| c.as_str().to_owned()) - .collect::>(), - ) - } else { - None - } - }) - .flatten() - .collect(); + let pk_columns = primary_key_columns(&table.constraints); let auto_increment = table.constraints.iter().any(|c| { matches!( @@ -225,69 +115,23 @@ fn render_entity_part( // Column order (not just membership) matters for CompositePrimaryKey's // positional args, so capture it separately from the `pk_columns` set. - let pk_columns_ordered: Vec = table - .constraints - .iter() - .find_map(|c| { - if let TableConstraint::PrimaryKey { columns, .. } = c { - Some(columns.iter().map(|c| c.as_str().to_owned()).collect()) - } else { - None - } - }) + let pk_columns_ordered = primary_key(&table.constraints) + .map(TableConstraint::columns) .unwrap_or_default(); - let single_unique_cols: 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 single_unique_cols = single_column_uniques(&table.constraints); + let fk_map = single_column_fk_details(&table.constraints); - // single-column FK info: col_name → (ref_table, on_delete, on_update) - let fk_map: HashMap, Option<&ReferenceAction>)> = table - .constraints - .iter() - .filter_map(|c| { - if let TableConstraint::ForeignKey { - columns, - ref_table, - ref_columns, - on_delete, - on_update, - .. - } = c - && columns.len() == 1 - && ref_columns.len() == 1 - { - return Some(( - columns[0].as_str().to_owned(), - (ref_table.as_str(), on_delete.as_ref(), on_update.as_ref()), - )); - } - None - }) - .collect(); + let class_name = class_of(names, &table.name); - // Enum class names for this table's columns + // Enum class names for this table's columns, as claimed in the module. let enum_class_map: HashMap<&str, String> = table .columns .iter() .filter_map(|col| { if let ColumnType::Complex(ComplexColumnType::Enum { name, .. }) = &col.r#type { - Some(( - col.name.as_str(), - sanitize_identifier(&to_pascal_case(name), IdentifierStart::Underscore), - )) + let class = names.enum_type(&table.name, name).to_string(); + Some((col.name.as_str(), class)) } else { None } @@ -295,23 +139,25 @@ fn render_entity_part( .collect(); // --- Enum class definitions --- - let mut seen_enums: HashSet = HashSet::new(); + let mut seen_enums: HashSet<&str> = HashSet::new(); for col in &table.columns { - if let ColumnType::Complex(ComplexColumnType::Enum { name, values }) = &col.r#type { - let class_name = - sanitize_identifier(&to_pascal_case(name), IdentifierStart::Underscore); - if seen_enums.insert(class_name.clone()) { - render_enum(&mut lines, &class_name, values); + if let ColumnType::Complex(ComplexColumnType::Enum { values, .. }) = &col.r#type { + let enum_class = enum_class_map[col.name.as_str()].as_str(); + if seen_enums.insert(enum_class) { + render_enum(&mut lines, enum_class, values); lines.push(String::new()); } } } // --- Class declaration --- - let class_name = sanitize_identifier(&to_pascal_case(&table.name), IdentifierStart::Underscore); if let Some(ref desc) = table.description { lines.push(format!("class {class_name}(models.Model):")); - lines.push(format!(" \"\"\"{}\"\"\"", desc.replace('\n', " "))); + // A docstring keeps its triple quotes: `string_literal` supplies the + // inner pair and escapes every `\` and `"` of the text, the two + // characters that could end it early. + let docstring = string_literal(&desc.replace('\n', " ")); + lines.push(format!(" \"\"{docstring}\"\"")); lines.push(String::new()); } else { lines.push(format!("class {class_name}(models.Model):")); @@ -323,33 +169,20 @@ fn render_entity_part( // of any `db_column` override). Without this, Django would fall back to // adding its own implicit auto `id` PK, which doesn't correspond to any // real uniqueness constraint on the actual table. - if is_composite_pk { - let attnames: Vec = pk_columns_ordered - .iter() - .map(|col| { - if fk_map.contains_key(col.as_str()) { - let (field_name, _) = fk_field_name(col); - format!("{field_name}_id") - } else { - col.clone() - } - }) - .collect(); - let args = attnames - .iter() - .map(|a| format!("\"{a}\"")) - .collect::>() - .join(", "); - lines.push(format!(" pk = models.CompositePrimaryKey({args})")); - } + // Rendered after the fields, which is where the attnames come from, + // but emitted here at the top of the class body. + let composite_pk_at = lines.len(); // --- Fields --- // Sanitizing distinct column names (e.g. `a_id` -> `a`, `a` -> `a`) can // collapse two originally-distinct columns onto the same Python // attribute name; disambiguate with a numeric suffix rather than // silently emitting a duplicate class attribute. - let mut used_field_names: HashSet = HashSet::new(); + let (field_names, mut used_field_names) = column_field_names(table, schema); + let mut attnames: HashMap<&str, String> = HashMap::new(); + let mut unrelatable: Vec = Vec::new(); for col in &table.columns { + let field_name = field_names[col.name.as_str()].as_str(); let is_pk = pk_columns.contains(col.name.as_str()); let is_unique = single_unique_cols.contains(col.name.as_str()); @@ -357,27 +190,38 @@ fn render_entity_part( lines.push(format!(" # {}", comment.replace('\n', " "))); } - if let Some(&(ref_table, on_delete, on_update)) = fk_map.get(col.name.as_str()) { - render_fk_field( - &mut lines, - &col.name, - ref_table, - on_delete, - on_update, - col.nullable, - &mut used_field_names, - ); + let effective_pk = is_pk && !is_composite_pk; + let fk = fk_map.get(col.name.as_str()); + if let Some(fk) = fk.filter(|fk| !is_relatable(fk, schema)) { + unrelatable.push(format!( + " # foreign key: ({}) -> {}({})", + col.name, fk.ref_table, fk.ref_column + )); + } + let attname = if let Some(fk) = fk.filter(|fk| is_relatable(fk, schema)) { + let field = ForeignKeyField { + column: &col.name, + name: field_name, + target_class: class_of(names, fk.ref_table), + to_field: to_field(fk, schema), + on_delete: fk.on_delete, + default: col + .default + .as_ref() + .and_then(|dv| build_default(&col.r#type, &dv.to_sql(), used)), + is_pk: effective_pk, + is_unique, + nullable: col.nullable, + }; + lines.push(field.render()); + // A ForeignKey's attname is `{field}_id` whatever `db_column` says. + format!("{field_name}_id") } else { - let effective_pk = is_pk && !is_composite_pk; let field_type = django_field_type( &col.r#type, effective_pk, auto_increment && !is_composite_pk, ); - let field_name = unique_name( - &sanitize_identifier(col.name.as_str(), IdentifierStart::Underscore), - &mut used_field_names, - ); let db_column = if field_name == col.name.as_str() { None } else { @@ -399,17 +243,52 @@ fn render_entity_part( } else { lines.push(format!(" {field_name} = {field_type}({kwargs_str})")); } - } + field_name.to_string() + }; + attnames.insert(col.name.as_str(), attname); + } + + if is_composite_pk { + let args = pk_columns_ordered + .iter() + .map(|col| format!("\"{}\"", attname_of(&attnames, col.as_str()))) + .collect::>() + .join(", "); + lines.insert( + composite_pk_at, + format!(" pk = models.CompositePrimaryKey({args})"), + ); } - for line in extra_fields { - lines.push(line.clone()); + // --- Many-to-many fields: the other side of each junction linking this + // table. Named after the pluralized target, or `{target}_via_{junction}` + // when two junctions reach one target; claimed after the columns so a + // field never shadows a scalar of the same name. + let mut target_counts: HashMap<&str, usize> = HashMap::new(); + for (target, _) in &m2m { + *target_counts.entry(target).or_default() += 1; + } + for (target, junction) in &m2m { + let base = pluralize(target); + let raw = if target_counts[target] > 1 { + format!("{base}_via_{junction}") + } else { + base + }; + let field_name = django_field_name(&raw, &mut used_field_names); + let target_class = class_of(names, target); + let junction_class = class_of(names, junction); + lines.push(format!( + " {field_name} = models.ManyToManyField(\"{target_class}\", through=\"{junction_class}\", related_name=\"+\")" + )); } - // Composite (multi-column) FKs have no native Django ORM field — surface - // them as a comment rather than silently dropping the relationship info. - // The individual columns still render above as plain scalar fields, and - // referential integrity is enforced by the generated database schema. + // Composite (multi-column) FKs have no native Django ORM field, and neither + // has a key into a composite-key model — surface them as a comment rather + // than silently dropping the relationship info. The individual columns + // still render above as plain scalar fields, and referential integrity is + // enforced by the generated database schema. + lines.extend(unrelatable); for fk in collect_composite_fks(table) { let local = fk.local_cols.join(", "); let refs = fk.ref_cols.join(", "); @@ -450,9 +329,15 @@ fn render_entity_part( lines.push(String::new()); lines.push(" class Meta:".into()); - lines.push(format!(" db_table = \"{}\"", table.name)); + // vespertide owns the DDL; `makemigrations` must not try to create or + // alter these tables. + lines.push(" managed = False".into()); + lines.push(format!( + " db_table = {}", + string_literal(&table.name) + )); if let Some(label) = app_label { - lines.push(format!(" app_label = \"{label}\"")); + lines.push(format!(" app_label = {}", string_literal(label))); } if !indexes.is_empty() { @@ -460,12 +345,19 @@ fn render_entity_part( for (name, cols) in &indexes { let fields = cols .iter() - .map(|c| format!("\"{c}\"")) + .map(|c| format!("\"{}\"", attname_of(&attnames, c))) .collect::>() .join(", "); - if let Some(n) = name { + // The name the SQL layer gave the index — a source name is the + // builder's key, not the final name — while it fits Django's + // 30-character cap (models.E034). Past the cap the index goes + // unnamed: Django never creates the index of an unmanaged model, + // so the name it makes up is never used. + let n = build_index_name(&table.name, cols, *name); + if n.len() <= 30 { lines.push(format!( - " models.Index(fields=[{fields}], name=\"{n}\")," + " models.Index(fields=[{fields}], name={}),", + string_literal(&n) )); } else { lines.push(format!(" models.Index(fields=[{fields}]),")); @@ -479,18 +371,16 @@ fn render_entity_part( for (name, cols) in &composite_uniques { let fields = cols .iter() - .map(|c| format!("\"{c}\"")) + .map(|c| format!("\"{}\"", attname_of(&attnames, c))) .collect::>() .join(", "); - if let Some(n) = name { - lines.push(format!( - " models.UniqueConstraint(fields=[{fields}], name=\"{n}\")," - )); - } else { - lines.push(format!( - " models.UniqueConstraint(fields=[{fields}])," - )); - } + // Likewise the database's name. A constraint must carry one, and + // Django puts no cap on it. + let n = build_unique_constraint_name(&table.name, cols, *name); + lines.push(format!( + " models.UniqueConstraint(fields=[{fields}], name={}),", + string_literal(&n) + )); } lines.push(" ]".into()); } @@ -499,66 +389,239 @@ fn render_entity_part( lines.join("\n") } -fn render_fk_field( - lines: &mut Vec, - col_name: &str, - ref_table: &str, - on_delete: Option<&ReferenceAction>, - on_update: Option<&ReferenceAction>, +/// A single-column foreign key Django can express, as the field it renders. +struct ForeignKeyField<'a> { + column: &'a str, + name: &'a str, + target_class: String, + to_field: Option, + on_delete: Option<&'a ReferenceAction>, + default: Option, + is_pk: bool, + is_unique: bool, nullable: bool, - used_field_names: &mut HashSet, -) { - let (field_name, db_column) = fk_field_name(col_name); - // The `_id` strip can collapse two distinct columns onto the same - // attribute name (e.g. `a_id` -> `a` colliding with a real column `a`). - let deduped_field_name = unique_name(&field_name, used_field_names); - let db_column = - db_column.or_else(|| (deduped_field_name != field_name).then(|| col_name.to_string())); - let field_name = deduped_field_name; - let ref_class = sanitize_identifier(&to_pascal_case(ref_table), IdentifierStart::Underscore); - let on_delete_str = on_delete.map_or("models.RESTRICT", reference_action_str); - - let _ = on_update; // Django ForeignKey has no on_update param; silently ignored - - let mut kwargs = vec![ - format!("\"{ref_class}\""), - format!("on_delete={on_delete_str}"), - ]; - if let Some(db_col) = db_column { - kwargs.push(format!("db_column=\"{db_col}\"")); +} + +impl ForeignKeyField<'_> { + fn render(&self) -> String { + // Django reads a ForeignKey through `{field}_id`, so the column keeps + // its database name exactly when the stripped base survives every + // rename. + let db_column = (format!("{}_id", self.name) != self.column).then_some(self.column); + let null = self.nullable && !self.is_pk; + // `ON UPDATE` has no counterpart on a Django ForeignKey. + let on_delete = on_delete_for(self.on_delete, self.default.is_some(), null); + + let mut kwargs = vec![ + format!("\"{}\"", self.target_class), + format!("on_delete={on_delete}"), + ]; + if let Some(to_field) = &self.to_field { + kwargs.push(format!("to_field={}", string_literal(to_field))); + } + if self.is_pk { + kwargs.push("primary_key=True".into()); + } + if let Some(default) = &self.default { + kwargs.push(format!("default={default}")); + } + if let Some(db_column) = db_column { + kwargs.push(format!("db_column={}", string_literal(db_column))); + } + kwargs.push("related_name=\"+\"".into()); + if null { + kwargs.push("null=True".into()); + kwargs.push("blank=True".into()); + } + + // A FK that is the PK or unique holds at most one row per target: + // Django's one-to-one. `ForeignKey(unique=True)` only draws fields.W342 + // pointing here. + let field_class = if self.is_pk || self.is_unique { + "models.OneToOneField" + } else { + "models.ForeignKey" + }; + format!(" {} = {field_class}({})", self.name, kwargs.join(", ")) } - kwargs.push("related_name=\"+\"".into()); - if nullable { - kwargs.push("null=True".into()); - kwargs.push("blank=True".into()); +} + +/// Whether Django can express `fk` as a relation. It cannot relate to a model +/// with a composite primary key (fields.E347), and the field a key references +/// must be unique (fields.E311): the target's primary key, or a column with a +/// unique of its own. Such a key stays a plain column. A target outside +/// `schema` is taken at its word. +fn is_relatable(fk: &FkDetails, schema: &[TableDef]) -> bool { + schema + .iter() + .find(|t| t.name.as_str() == fk.ref_table) + .is_none_or(|target| { + let pk = primary_key_columns(&target.constraints); + pk.len() < 2 + && (pk.contains(fk.ref_column) + || single_column_uniques(&target.constraints).contains(fk.ref_column)) + }) +} + +/// The `to_field` a foreign key needs: the target's field for the referenced +/// column, whenever that column is not the target's primary key — which is +/// what Django would otherwise join on. +fn to_field(fk: &FkDetails, schema: &[TableDef]) -> Option { + let target = schema.iter().find(|t| t.name.as_str() == fk.ref_table)?; + if primary_key_columns(&target.constraints).contains(fk.ref_column) { + return None; } + let (target_fields, _) = column_field_names(target, schema); + Some( + target_fields + .get(fk.ref_column) + .map_or(fk.ref_column, String::as_str) + .to_string(), + ) +} - let kwargs_str = kwargs.join(", "); - lines.push(format!( - " {field_name} = models.ForeignKey({kwargs_str})" - )); +/// The Django field name of every column of `table`, claimed in declaration +/// order, with the set those claims filled. A foreign key Django can express +/// is named after its relation (`user_id` -> `user`), every other column after +/// itself. Sanitizing distinct columns (`a_id` -> `a`, `a` -> `a`) can land two +/// of them on one attribute; the later one is numbered. +fn column_field_names<'a>( + table: &'a TableDef, + schema: &[TableDef], +) -> (HashMap<&'a str, String>, HashSet) { + let fk_map = single_column_fk_details(&table.constraints); + let mut taken = HashSet::new(); + let names = table + .columns + .iter() + .map(|col| { + let is_relation = fk_map + .get(col.name.as_str()) + .is_some_and(|fk| is_relatable(fk, schema)); + let name = if is_relation { + let base = vespertide_naming::infer_relation_field_name(&col.name); + claim_relation_field_name(&django_identifier(base), &mut taken) + } else { + django_field_name(&col.name, &mut taken) + }; + (col.name.as_str(), name) + }) + .collect(); + (names, taken) } -/// Returns (field_name, Option). -/// If col_name ends with `_id`, strip it — Django automatically appends `_id`. -/// Otherwise, emit db_column explicitly so Django uses the raw column name. -/// Either way, `field_name` is sanitized into a valid Python identifier; if -/// that sanitization (or the `_id` strip) changes anything, `db_column` is -/// set to the original column name so the DB mapping isn't lost. -fn fk_field_name(col_name: &str) -> (String, Option) { - if let Some(base) = col_name.strip_suffix("_id") { - let sanitized = sanitize_identifier(base, IdentifierStart::Underscore); - if sanitized == base { - (sanitized, None) - } else { - (sanitized, Some(col_name.to_string())) - } - } else { - ( - sanitize_identifier(col_name, IdentifierStart::Underscore), - Some(col_name.to_string()), - ) +/// Claim a relation's field name together with its attname: Django stores the +/// key under `{field}_id`, so a plain `owner_id` column next to an `owner` key +/// would share that attribute with it (models.E006). The first numbered name +/// with both free wins. +fn claim_relation_field_name(preferred: &str, taken: &mut HashSet) -> String { + let mut name = preferred.to_string(); + let mut n = 2usize; + while taken.contains(&name) || taken.contains(&format!("{name}_id")) { + name = format!("{preferred}{n}"); + n += 1; + } + taken.insert(format!("{name}_id")); + taken.insert(name.clone()); + name +} + +/// Every class `tables` declare in their module: models, then choices classes. +fn module_names(tables: &[TableDef]) -> ScopeNames { + ScopeNames::collect(tables, model_class_name, enum_class_name) +} + +/// The class a table is declared as; a table outside the module's schema — a +/// foreign key may point there — keeps its natural name. +fn class_of(names: &ScopeNames, table: &str) -> String { + names + .table(table) + .map_or_else(|| model_class_name(table), str::to_string) +} + +/// A table's model class. Django rejects a model name that starts with `_` +/// (models.E023), so a name that cannot lead with its own first character +/// gains a letter instead. +fn model_class_name(table: &str) -> String { + sanitize_identifier(&to_pascal_case(table), IdentifierStart::Letter) +} + +/// An enum's choices class. A model names it from inside its own class body, +/// where Python would mangle a `__`-led name. +fn enum_class_name(name: &str) -> String { + unmangled(sanitize_identifier( + &to_pascal_case(name), + IdentifierStart::Underscore, + )) +} + +/// What Django calls a column inside `Meta.indexes`, `Meta.constraints` and +/// `CompositePrimaryKey`: the declared field name, or a ForeignKey's attname. +/// Those three resolve against field names only — never `db_column` — so a +/// column whose name had to be escaped is unreachable under its database +/// spelling. +fn attname_of<'a>(attnames: &'a HashMap<&str, String>, column: &'a str) -> &'a str { + attnames.get(column).map_or(column, String::as_str) +} + +/// Attributes every Django model already has: `Model`'s public API and what its +/// metaclass adds. A field of the same name replaces the method (`save`, +/// `clean`: a `TypeError` at the first call), fails the checks (`pk`: +/// fields.E003, `check`: models.E020), stops the module importing (`objects`) +/// or is rebound by the `class Meta` written below the fields (`Meta`). +const MODEL_ATTRIBUTES: &[&str] = &[ + "DoesNotExist", + "Meta", + "MultipleObjectsReturned", + "NotUpdated", + "adelete", + "arefresh_from_db", + "asave", + "check", + "clean", + "clean_fields", + "date_error_message", + "delete", + "from_db", + "full_clean", + "get_constraints", + "get_deferred_fields", + "objects", + "pk", + "prepare_database_save", + "refresh_from_db", + "save", + "save_base", + "serializable_value", + "unique_error_message", + "validate_constraints", + "validate_unique", +]; + +/// A column's Django field name: its [`django_identifier`], claimed against +/// `taken`. Callers emit `db_column` whenever the result differs from the +/// column. +fn django_field_name(column: &str, taken: &mut HashSet) -> String { + claim_binding(django_identifier(column), taken) +} + +/// A Python identifier that also passes Django's field checks — no `__` (the +/// lookup separator, fields.E002), no trailing `_` (fields.E001), not a +/// keyword and not one of the model's own attributes. The repairs are +/// `inspectdb`'s, so a renamed field reads the way Django's own tooling would +/// spell it. +fn django_identifier(column: &str) -> String { + let mut name = sanitize_identifier(column, IdentifierStart::Underscore); + while name.contains("__") { + name = name.replace("__", "_"); + } + if name.ends_with('_') { + name.push_str("field"); + } + if MODEL_ATTRIBUTES.contains(&name.as_str()) || is_python_keyword(&name) { + name.push_str("_field"); } + name } fn assemble_with_imports(used: &UsedImports, parts: &[String]) -> String { @@ -582,90 +645,99 @@ fn assemble_with_imports(used: &UsedImports, parts: &[String]) -> String { lines.join("\n") } -pub(super) 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_upper_snake_case(s: &str) -> String { - let mut result = String::new(); - let chars: Vec = s.chars().collect(); - for (i, &c) in chars.iter().enumerate() { - if c == '-' || c == ' ' { - if !result.ends_with('_') { - result.push('_'); - } - } else if c == '_' { - result.push('_'); - } else if c.is_uppercase() && i > 0 && !result.ends_with('_') { - // Only split on camelCase transitions (lowercase/digit → uppercase). - // Adjacent uppercase letters (e.g. "ERROR") are not split. - let prev = chars[i - 1]; - if prev.is_lowercase() || prev.is_ascii_digit() { - result.push('_'); - } - result.push(c); - } else { - result.push(c.to_ascii_uppercase()); - } - } - // Python identifiers cannot start with a digit - if result.starts_with(|c: char| c.is_ascii_digit()) { - result.insert(0, '_'); - } - result -} - #[cfg(test)] mod tests { + use vespertide_core::schema::column::SimpleColumnType; + use super::*; + use crate::tests::fixtures::{fk, pk, simple}; #[rstest::rstest] - #[case("pending", "PENDING")] - #[case("in_progress", "IN_PROGRESS")] - #[case("inProgress", "IN_PROGRESS")] - #[case("ERROR_LEVEL", "ERROR_LEVEL")] - #[case("info-level", "INFO_LEVEL")] - #[case("1critical", "_1CRITICAL")] - fn test_to_upper_snake_case(#[case] input: &str, #[case] expected: &str) { - assert_eq!(to_upper_snake_case(input), expected); + #[case::plain("author", "author")] + #[case::keyword("from", "from_field")] + #[case::reserved_pk("pk", "pk_field")] + #[case::model_method("save", "save_field")] + #[case::model_check("check", "check_field")] + #[case::default_manager("objects", "objects_field")] + #[case::options_class("Meta", "Meta_field")] + #[case::lookup_separator("user__name", "user_name")] + #[case::trailing_underscore("total_", "total_field")] + #[case::separator_from_sanitizing("a--b", "a_b")] + #[case::digit_led("1st", "_1st")] + fn django_field_name_passes_the_field_checks(#[case] column: &str, #[case] expected: &str) { + let mut taken = HashSet::new(); + assert_eq!(django_field_name(column, &mut taken), expected); + } + + fn owners(constraints: Vec) -> TableDef { + TableDef { + name: "owners".into(), + description: None, + columns: vec![ + simple("id", SimpleColumnType::Integer), + simple("region", SimpleColumnType::Integer), + simple("code", SimpleColumnType::Integer), + ], + constraints, + } } + fn unique(column: &str) -> TableConstraint { + TableConstraint::Unique { + name: None, + columns: vec![column.into()], + strategy: vespertide_core::UniqueConstraintStrategy::DeleteDuplicates { + keep: vespertide_core::KeepPolicy::First, + }, + } + } + + /// A key's attname is `{field}_id`, so the key and a column of that name + /// cannot both keep theirs, whichever the table declares first. #[rstest::rstest] - #[case("author_id", "author", None)] - #[case("user_id", "user", None)] - #[case("parent", "parent", Some("parent"))] - #[case("ref", "ref", Some("ref"))] - fn test_fk_field_name( - #[case] col: &str, - #[case] expected_field: &str, - #[case] expected_db_col: Option<&str>, + #[case::key_first(&["owner", "owner_id"], &["owner", "owner_id2"])] + #[case::column_first(&["owner_id", "owner"], &["owner_id", "owner2"])] + fn a_key_claims_its_attname_with_its_field_name( + #[case] columns: &[&str], + #[case] expected: &[&str], ) { - let (field, db_col) = fk_field_name(col); - assert_eq!(field, expected_field); - assert_eq!(db_col.as_deref(), expected_db_col); + let table = TableDef { + name: "pets".into(), + description: None, + columns: columns + .iter() + .map(|name| simple(name, SimpleColumnType::Integer)) + .collect(), + constraints: vec![fk(&["owner"], "owners", &["id"])], + }; + let (names, _) = column_field_names(&table, &[]); + let names: Vec<&str> = columns.iter().map(|c| names[c].as_str()).collect(); + assert_eq!(names, expected); } - #[test] - fn test_to_pascal_case_double_underscore() { - // Double underscore produces an empty word, triggering the None arm in to_pascal_case - assert_eq!(to_pascal_case("order__item"), "OrderItem"); - assert_eq!(to_pascal_case("_leading"), "Leading"); - assert_eq!(to_pascal_case("trailing_"), "Trailing"); + #[rstest::rstest] + #[case::primary_key(vec![pk(&["id"])], "id", true)] + #[case::unique_column(vec![pk(&["id"]), unique("code")], "code", true)] + #[case::column_that_is_not_unique(vec![pk(&["id"])], "code", false)] + #[case::part_of_a_composite_key(vec![pk(&["id", "region"])], "id", false)] + fn a_key_is_a_relation_only_onto_a_unique_field_of_a_single_key_model( + #[case] target_constraints: Vec, + #[case] ref_column: &str, + #[case] expected: bool, + ) { + let schema = [owners(target_constraints)]; + let key = fk(&["owner_id"], "owners", &[ref_column]); + let fk_map = single_column_fk_details(std::slice::from_ref(&key)); + assert_eq!(is_relatable(&fk_map["owner_id"], &schema), expected); + // A target the schema does not hold is taken at its word. + assert!(is_relatable(&fk_map["owner_id"], &[])); } - #[test] - fn test_unique_name_double_collision_appends_incrementing_suffix() { - let mut used = HashSet::new(); - used.insert("tag".to_string()); - used.insert("tag_2".to_string()); - assert_eq!(unique_name("tag", &mut used), "tag_3"); + #[rstest::rstest] + #[case::plain("order_status", "OrderStatus")] + #[case::digit_led("1st", "_1st")] + #[case::leading_run_python_would_mangle("--kind", "_kind")] + fn enum_class_name_can_be_named_from_a_model(#[case] name: &str, #[case] expected: &str) { + assert_eq!(enum_class_name(name), expected); } } diff --git a/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__composite_pk.snap b/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__composite_pk.snap deleted file mode 100644 index b5332681..00000000 --- a/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__composite_pk.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -source: crates/vespertide-exporter/src/django/mod.rs -assertion_line: 238 -expression: render_entity(&table).unwrap() ---- -from __future__ import annotations - -from django.db import models - - -class OrderItems(models.Model): - pk = models.CompositePrimaryKey("order_id", "product_id") - order_id = models.IntegerField() - product_id = models.IntegerField() - quantity = models.IntegerField() - - class Meta: - db_table = "order_items" diff --git a/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__indexes_and_composite_unique.snap b/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__indexes_and_composite_unique.snap deleted file mode 100644 index b98c8390..00000000 --- a/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__indexes_and_composite_unique.snap +++ /dev/null @@ -1,24 +0,0 @@ ---- -source: crates/vespertide-exporter/src/django/mod.rs -assertion_line: 252 -expression: render_entity(&table).unwrap() ---- -from __future__ import annotations - -from django.db import models - - -class Articles(models.Model): - id = models.AutoField(primary_key=True) - slug = models.CharField(max_length=200) - author_id = models.IntegerField() - created_at = models.DateTimeField() - - class Meta: - db_table = "articles" - indexes = [ - models.Index(fields=["created_at"], name="ix_articles__created_at"), - ] - constraints = [ - models.UniqueConstraint(fields=["slug", "author_id"], name="uq_articles__slug_author"), - ] diff --git a/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__server_default_timezone.snap b/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__server_default_timezone.snap deleted file mode 100644 index 2cc3d147..00000000 --- a/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__server_default_timezone.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -source: crates/vespertide-exporter/src/django/mod.rs -assertion_line: 286 -expression: result ---- -from __future__ import annotations - -from django.utils import timezone -from django.db import models - - -class Events(models.Model): - id = models.AutoField(primary_key=True) - created_at = models.DateTimeField(default=timezone.now) - count = models.IntegerField(default=0) - - class Meta: - db_table = "events" diff --git a/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__table_with_fk.snap b/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__table_with_fk.snap deleted file mode 100644 index 9b1c8e90..00000000 --- a/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__table_with_fk.snap +++ /dev/null @@ -1,17 +0,0 @@ ---- -source: crates/vespertide-exporter/src/django/mod.rs -assertion_line: 128 -expression: render_entity(&table).unwrap() ---- -from __future__ import annotations - -from django.db import models - - -class Posts(models.Model): - id = models.AutoField(primary_key=True) - author = models.ForeignKey("Users", on_delete=models.CASCADE, related_name="+") - title = models.TextField() - - class Meta: - db_table = "posts" diff --git a/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__table_with_string_enum.snap b/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__table_with_string_enum.snap deleted file mode 100644 index 2b208135..00000000 --- a/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__table_with_string_enum.snap +++ /dev/null @@ -1,21 +0,0 @@ ---- -source: crates/vespertide-exporter/src/django/mod.rs -assertion_line: 157 -expression: render_entity(&table).unwrap() ---- -from __future__ import annotations - -from django.db import models - - -class OrderStatus(models.TextChoices): - PENDING = "pending", "pending" - SHIPPED = "shipped", "shipped" - DELIVERED = "delivered", "delivered" - -class Orders(models.Model): - id = models.AutoField(primary_key=True) - status = models.CharField(max_length=9, choices=OrderStatus.choices, default="pending") - - class Meta: - db_table = "orders" diff --git a/crates/vespertide-exporter/src/django/types.rs b/crates/vespertide-exporter/src/django/types.rs index 913e97ab..25a3107c 100644 --- a/crates/vespertide-exporter/src/django/types.rs +++ b/crates/vespertide-exporter/src/django/types.rs @@ -1,7 +1,9 @@ -use vespertide_core::DefaultValue; use vespertide_core::schema::column::{ - ColumnType, ComplexColumnType, EnumValues, SimpleColumnKind, SimpleColumnType, + ColumnType, ComplexColumnType, EnumValues, SimpleColumnType, }; +use vespertide_core::{DefaultValue, ReferenceAction}; + +use crate::utils::common::{is_jsonb_custom_type, string_literal, unquote}; #[derive(Default)] pub(super) struct UsedImports { @@ -15,63 +17,66 @@ pub(super) fn django_field_type( auto_increment: bool, ) -> &'static str { match col_type { - ColumnType::Simple(ty) => match SimpleColumnKind::from(*ty) { - SimpleColumnKind::SmallInt => { + ColumnType::Simple(ty) => match ty { + SimpleColumnType::SmallInt => { if is_pk && auto_increment { "models.SmallAutoField" } else { "models.SmallIntegerField" } } - SimpleColumnKind::Integer => { + SimpleColumnType::Integer => { if is_pk && auto_increment { "models.AutoField" } else { "models.IntegerField" } } - SimpleColumnKind::BigInt => { + SimpleColumnType::BigInt => { if is_pk && auto_increment { "models.BigAutoField" } else { "models.BigIntegerField" } } - SimpleColumnKind::Real | SimpleColumnKind::DoublePrecision => "models.FloatField", - SimpleColumnKind::Text | SimpleColumnKind::Xml => "models.TextField", - SimpleColumnKind::Boolean => "models.BooleanField", - SimpleColumnKind::Date => "models.DateField", - SimpleColumnKind::Time => "models.TimeField", - SimpleColumnKind::Timestamp | SimpleColumnKind::Timestamptz => "models.DateTimeField", - SimpleColumnKind::Interval => "models.DurationField", - SimpleColumnKind::Bytea => "models.BinaryField", - SimpleColumnKind::Uuid => "models.UUIDField", - SimpleColumnKind::Json => "models.JSONField", - SimpleColumnKind::Inet | SimpleColumnKind::Cidr => "models.GenericIPAddressField", - SimpleColumnKind::Macaddr => "models.CharField", + SimpleColumnType::Real | SimpleColumnType::DoublePrecision => "models.FloatField", + SimpleColumnType::Text | SimpleColumnType::Xml => "models.TextField", + SimpleColumnType::Boolean => "models.BooleanField", + SimpleColumnType::Date => "models.DateField", + SimpleColumnType::Time => "models.TimeField", + SimpleColumnType::Timestamp | SimpleColumnType::Timestamptz => "models.DateTimeField", + SimpleColumnType::Interval => "models.DurationField", + SimpleColumnType::Bytea => "models.BinaryField", + SimpleColumnType::Uuid => "models.UUIDField", + SimpleColumnType::Json => "models.JSONField", + SimpleColumnType::Inet | SimpleColumnType::Cidr => "models.GenericIPAddressField", + SimpleColumnType::Macaddr => "models.CharField", }, ColumnType::Complex(ty) => match ty { ComplexColumnType::Varchar { .. } | ComplexColumnType::Char { .. } => { "models.CharField" } ComplexColumnType::Numeric { .. } => "models.DecimalField", + // Postgres has no implicit `text -> jsonb` cast, so a `TextField` on a + // JSONB column fails every write. + ComplexColumnType::Custom { custom_type } if is_jsonb_custom_type(custom_type) => { + "models.JSONField" + } ComplexColumnType::Custom { .. } => "models.TextField", ComplexColumnType::Enum { values, .. } => match values { EnumValues::String(_) => "models.CharField", EnumValues::Integer(_) => "models.IntegerField", }, - // `#[non_exhaustive]` future-variant guard; unreachable today. - #[cfg(not(tarpaulin_include))] - _ => { - unreachable!("ComplexColumnType is #[non_exhaustive]; all variants matched") - } + _ => unreachable!( + "ComplexColumnType is #[non_exhaustive]; all variants are matched above" + ), }, } } #[expect( clippy::too_many_arguments, - reason = "all params are independent field-kwarg inputs; a context struct would add noise without reducing coupling" + reason = "independent field-kwarg inputs, read once at a single call site" )] pub(super) fn build_field_kwargs( col_type: &ColumnType, @@ -86,7 +91,7 @@ pub(super) fn build_field_kwargs( let mut kwargs: Vec = Vec::new(); if let Some(db_col) = db_column { - kwargs.push(format!("db_column=\"{db_col}\"")); + kwargs.push(format!("db_column={}", string_literal(db_col))); } // Size / precision kwargs @@ -146,6 +151,13 @@ pub(super) fn build_default( sql: &str, used: &mut UsedImports, ) -> Option { + // A `JSONField` default has to be a callable (fields.E010), and the SQL + // literal is the document's text, not its value. The database keeps its + // own default. + if django_field_type(col_type, false, false) == "models.JSONField" { + return None; + } + if sql.contains('(') { let up = sql.to_uppercase(); let is_timestamp_col = matches!( @@ -171,9 +183,10 @@ pub(super) fn build_default( return Some("False".into()); } - if sql.starts_with('\'') && sql.ends_with('\'') && sql.len() >= 2 { - let inner = &sql[1..sql.len() - 1]; - return Some(format!("\"{}\"", inner.replace('"', "\\\""))); + if sql.len() >= 2 && sql.starts_with('\'') && sql.ends_with('\'') { + // `unquote` keeps the doubled SQL escape (its other consumers re-emit + // into SQL); a Python string wants the actual value. + return Some(string_literal(&unquote(sql).replace("''", "'"))); } // A bare numeric literal (e.g. "0", "-1.5") is valid Python as-is. Any @@ -188,13 +201,30 @@ pub(super) fn build_default( None } -pub(super) fn reference_action_str(action: &vespertide_core::ReferenceAction) -> &'static str { - use vespertide_core::ReferenceActionKind; - match ReferenceActionKind::from(action) { - ReferenceActionKind::Cascade => "models.CASCADE", - ReferenceActionKind::Restrict => "models.RESTRICT", - ReferenceActionKind::SetNull => "models.SET_NULL", - ReferenceActionKind::SetDefault => "models.SET_DEFAULT", - ReferenceActionKind::NoAction => "models.DO_NOTHING", +/// The `on_delete` a ForeignKey can carry; a key without an action restricts. +/// Django emulates the action itself and rejects one the field cannot carry +/// out: SET_DEFAULT without a default (fields.E321), SET_NULL on a field that +/// is not null (fields.E320). The table is unmanaged, so the database still +/// applies its own rule; DO_NOTHING leaves it to. +pub(super) fn on_delete_for( + action: Option<&ReferenceAction>, + has_default: bool, + null: bool, +) -> &'static str { + match action { + Some(ReferenceAction::SetDefault) if !has_default => "models.DO_NOTHING", + Some(ReferenceAction::SetNull) if !null => "models.DO_NOTHING", + Some(action) => reference_action_str(action), + None => "models.RESTRICT", + } +} + +fn reference_action_str(action: &ReferenceAction) -> &'static str { + match action { + ReferenceAction::Cascade => "models.CASCADE", + ReferenceAction::Restrict => "models.RESTRICT", + ReferenceAction::SetNull => "models.SET_NULL", + ReferenceAction::SetDefault => "models.SET_DEFAULT", + ReferenceAction::NoAction => "models.DO_NOTHING", } } diff --git a/crates/vespertide-exporter/src/enum_scan.rs b/crates/vespertide-exporter/src/enum_scan.rs index 9da1a084..87ba5cca 100644 --- a/crates/vespertide-exporter/src/enum_scan.rs +++ b/crates/vespertide-exporter/src/enum_scan.rs @@ -2,10 +2,10 @@ //! one scope. //! //! 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`). +//! Drizzle, GORM and Django 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 and +//! Django claim them in the file's one scope (see `scope_names`). use vespertide_core::TableDef; use vespertide_core::schema::column::{ColumnType, ComplexColumnType, EnumValues}; diff --git a/crates/vespertide-exporter/src/lib.rs b/crates/vespertide-exporter/src/lib.rs index 0c845cfc..f678cd1a 100644 --- a/crates/vespertide-exporter/src/lib.rs +++ b/crates/vespertide-exporter/src/lib.rs @@ -1,7 +1,8 @@ //! Helpers to convert `TableDef` models into ORM-specific representations -//! such as `SeaORM`, `SQLAlchemy`, `SQLModel`, JPA, Prisma, Drizzle, and GORM. +//! such as `SeaORM`, `SQLAlchemy`, `SQLModel`, JPA, Prisma, Drizzle, GORM, and Django. mod constraint_scan; +pub mod django; pub mod drizzle; mod enum_scan; pub mod gorm; @@ -18,6 +19,7 @@ pub mod sqlmodel; mod tests; mod utils; +pub use django::DjangoExporter; pub use drizzle::DrizzleExporter; pub use gorm::GormExporter; pub use jpa::JpaExporter; diff --git a/crates/vespertide-exporter/src/orm.rs b/crates/vespertide-exporter/src/orm.rs index 29f8ea45..38676908 100644 --- a/crates/vespertide-exporter/src/orm.rs +++ b/crates/vespertide-exporter/src/orm.rs @@ -1,8 +1,9 @@ use vespertide_core::TableDef; use crate::{ - drizzle::DrizzleExporter, gorm::GormExporter, jpa::JpaExporter, prisma::PrismaExporter, - seaorm::SeaOrmExporter, sqlalchemy::SqlAlchemyExporter, sqlmodel::SqlModelExporter, + django::DjangoExporter, drizzle::DrizzleExporter, gorm::GormExporter, jpa::JpaExporter, + prisma::PrismaExporter, seaorm::SeaOrmExporter, sqlalchemy::SqlAlchemyExporter, + sqlmodel::SqlModelExporter, }; /// Supported ORM targets. @@ -18,6 +19,7 @@ pub enum Orm { Prisma, Drizzle, Gorm, + Django, } impl Orm { @@ -25,7 +27,7 @@ impl Orm { pub fn file_extension(self) -> &'static str { match self { Orm::SeaOrm => "rs", - Orm::SqlAlchemy | Orm::SqlModel => "py", + Orm::SqlAlchemy | Orm::SqlModel | Orm::Django => "py", Orm::Jpa => "java", Orm::Prisma => "prisma", Orm::Drizzle => "ts", @@ -59,6 +61,7 @@ pub fn render_entity(orm: Orm, table: &TableDef) -> Result { Orm::Prisma => PrismaExporter.render_entity(table), Orm::Drizzle => DrizzleExporter.render_entity(table), Orm::Gorm => GormExporter.render_entity(table), + Orm::Django => DjangoExporter.render_entity(table), } } @@ -76,6 +79,7 @@ pub fn render_entity_with_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), + Orm::Django => DjangoExporter.render_entity_with_schema(table, schema), } } @@ -93,6 +97,7 @@ mod tests { #[case::prisma(Orm::Prisma)] #[case::drizzle(Orm::Drizzle)] #[case::gorm(Orm::Gorm)] + #[case::django(Orm::Django)] fn dispatch_render_entity_succeeds(#[case] orm: Orm) { let table = basic_single_pk(); assert!(render_entity(orm, &table).is_ok()); @@ -106,6 +111,7 @@ mod tests { #[case::prisma(Orm::Prisma)] #[case::drizzle(Orm::Drizzle)] #[case::gorm(Orm::Gorm)] + #[case::django(Orm::Django)] fn dispatch_render_entity_with_schema_succeeds(#[case] orm: Orm) { let table = basic_single_pk(); let schema = vec![table.clone()]; @@ -120,6 +126,7 @@ mod tests { #[case::prisma(Orm::Prisma, "prisma")] #[case::drizzle(Orm::Drizzle, "ts")] #[case::gorm(Orm::Gorm, "go")] + #[case::django(Orm::Django, "py")] fn file_extension_matches_backend(#[case] orm: Orm, #[case] expected: &str) { assert_eq!(orm.file_extension(), expected); } @@ -134,6 +141,7 @@ mod tests { #[case::prisma("prisma", Orm::Prisma)] #[case::drizzle("drizzle", Orm::Drizzle)] #[case::gorm("gorm", Orm::Gorm)] + #[case::django("django", Orm::Django)] 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/scope_names.rs b/crates/vespertide-exporter/src/scope_names.rs index fbf67e9c..80fff93b 100644 --- a/crates/vespertide-exporter/src/scope_names.rs +++ b/crates/vespertide-exporter/src/scope_names.rs @@ -1,8 +1,8 @@ //! 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 +//! GORM writes every struct, enum type and enum constant into one Go package; +//! Django writes every model and choices class into one module. 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 diff --git a/crates/vespertide-exporter/src/tests/fixtures/identifiers.rs b/crates/vespertide-exporter/src/tests/fixtures/identifiers.rs index 556e506c..9a71f2a3 100644 --- a/crates/vespertide-exporter/src/tests/fixtures/identifiers.rs +++ b/crates/vespertide-exporter/src/tests/fixtures/identifiers.rs @@ -113,3 +113,25 @@ pub(crate) fn enum_name_shared_across_tables() -> Vec { }) .collect() } + +/// Column names that are legal in SQL but not as Python attributes or Django +/// fields: keywords, Django's reserved `pk`, the `__` lookup separator, a +/// trailing `_`, a separator the sanitizer itself introduces, and a FK whose +/// `_id`-stripped base is a keyword. The table name carries `__` too. +pub(crate) fn python_reserved_names() -> TableDef { + TableDef { + name: "event__log".into(), + description: None, + columns: vec![ + simple("id", SimpleColumnType::Integer), + simple("from", SimpleColumnType::Text), + simple("def", SimpleColumnType::Text), + simple("pk", SimpleColumnType::Integer), + simple("user__name", SimpleColumnType::Text), + simple("total_", SimpleColumnType::Integer), + simple("a--b", SimpleColumnType::Text), + simple("pass_id", SimpleColumnType::Integer), + ], + constraints: vec![pk(&["id"]), fk(&["pass_id"], "targets", &["id"])], + } +} diff --git a/crates/vespertide-exporter/src/tests/fixtures/junctions.rs b/crates/vespertide-exporter/src/tests/fixtures/junctions.rs new file mode 100644 index 00000000..5d0713e6 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/fixtures/junctions.rs @@ -0,0 +1,49 @@ +//! Junction tables a backend's many-to-many support has to tell apart. + +use vespertide_core::TableDef; +use vespertide_core::schema::column::SimpleColumnType; + +use super::{fk, pk, simple}; + +/// A junction whose far end has a composite primary key, so it reaches that +/// end by a composite foreign key. Whether that still counts as a +/// many-to-many is up to the backend: Django cannot relate to a composite-key +/// model at all, and leaves all three as plain models. +pub(crate) fn junction_over_composite_key() -> Vec { + let user = TableDef { + name: "user".into(), + description: None, + columns: vec![simple("id", SimpleColumnType::Integer)], + constraints: vec![pk(&["id"])], + }; + let article = TableDef { + name: "article".into(), + description: None, + columns: vec![ + simple("media_id", SimpleColumnType::Integer), + simple("id", SimpleColumnType::Integer), + ], + constraints: vec![pk(&["media_id", "id"])], + }; + let article_user = TableDef { + name: "article_user".into(), + description: None, + columns: vec![ + simple("media_id", SimpleColumnType::Integer), + simple("article_id", SimpleColumnType::Integer), + simple("user_id", SimpleColumnType::Integer), + ], + constraints: vec![ + pk(&["media_id", "article_id", "user_id"]), + fk(&["media_id", "article_id"], "article", &["media_id", "id"]), + fk(&["user_id"], "user", &["id"]), + ], + }; + [user, article, article_user] + .into_iter() + .map(|t| { + t.normalize() + .expect("junction_over_composite_key normalizes") + }) + .collect() +} diff --git a/crates/vespertide-exporter/src/tests/fixtures/mod.rs b/crates/vespertide-exporter/src/tests/fixtures/mod.rs index 8f758fad..c4666cab 100644 --- a/crates/vespertide-exporter/src/tests/fixtures/mod.rs +++ b/crates/vespertide-exporter/src/tests/fixtures/mod.rs @@ -14,8 +14,13 @@ pub(crate) use collisions::binding_collisions; mod reference_actions; pub(crate) use reference_actions::reference_actions; +mod junctions; +pub(crate) use junctions::junction_over_composite_key; + mod identifiers; -pub(crate) use identifiers::{enum_name_shared_across_tables, relation_field_names}; +pub(crate) use identifiers::{ + enum_name_shared_across_tables, python_reserved_names, relation_field_names, +}; pub(crate) fn col(name: &str, ty: ColumnType) -> ColumnDef { ColumnDef::new(name, ty, false) diff --git a/crates/vespertide-exporter/src/tests/fixtures/reference_actions.rs b/crates/vespertide-exporter/src/tests/fixtures/reference_actions.rs index 2ce6f757..74b4acec 100644 --- a/crates/vespertide-exporter/src/tests/fixtures/reference_actions.rs +++ b/crates/vespertide-exporter/src/tests/fixtures/reference_actions.rs @@ -7,13 +7,14 @@ 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 +/// GORM, Prisma and Drizzle render both, Django renders `on_delete` alone +/// (its `ForeignKey` has no update action), 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. +/// (`comments.author_id`) and without one: Django accepts only the former. pub(crate) fn reference_actions() -> Vec { let users = TableDef { name: "users".into(), diff --git a/crates/vespertide-exporter/src/tests/fixtures/schemas.rs b/crates/vespertide-exporter/src/tests/fixtures/schemas.rs index f5ede44b..662719da 100644 --- a/crates/vespertide-exporter/src/tests/fixtures/schemas.rs +++ b/crates/vespertide-exporter/src/tests/fixtures/schemas.rs @@ -134,6 +134,99 @@ pub(crate) fn schema_scenario(name: &str) -> (TableDef, Vec) { // 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`). + "many_to_many_reserved_names" => { + let user = table_with_named_pk( + "user", + vec![ + simple("id", SimpleColumnType::Integer), + simple("tags", SimpleColumnType::Text), + ], + &["id"], + ); + let tags = table_with_named_pk( + "tags", + vec![simple("id", SimpleColumnType::Integer)], + &["id"], + ); + let pass = table_with_named_pk( + "pass", + vec![simple("id", SimpleColumnType::Integer)], + &["id"], + ); + let user_tags = table_with_fk_constraints( + "user_tags", + vec![ + simple("user_id", SimpleColumnType::Integer), + simple("tag_id", SimpleColumnType::Integer), + ], + &["user_id", "tag_id"], + vec![ + (vec!["user_id"], "user", vec!["id"]), + (vec!["tag_id"], "tags", vec!["id"]), + ], + ); + let user_pass = table_with_fk_constraints( + "user-pass", + vec![ + simple("user_id", SimpleColumnType::Integer), + simple("pass_id", SimpleColumnType::Integer), + ], + &["user_id", "pass_id"], + vec![ + (vec!["user_id"], "user", vec!["id"]), + (vec!["pass_id"], "pass", vec!["id"]), + ], + ); + (user.clone(), vec![user, tags, pass, user_tags, user_pass]) + } + // A self-junction (both keys -> `user`) and a junction between two other + // tables: neither is a many-to-many of `user`. + "many_to_many_uninvolved" => { + let user = table_with_named_pk( + "user", + vec![simple("id", SimpleColumnType::Integer)], + &["id"], + ); + let user_friends = table_with_fk_constraints( + "user_friends", + vec![ + simple("user_id", SimpleColumnType::Integer), + simple("friend_id", SimpleColumnType::Integer), + ], + &["user_id", "friend_id"], + vec![ + (vec!["user_id"], "user", vec!["id"]), + (vec!["friend_id"], "user", vec!["id"]), + ], + ); + let groups = table_with_named_pk( + "groups", + vec![simple("id", SimpleColumnType::Integer)], + &["id"], + ); + let tags = table_with_named_pk( + "tags", + vec![simple("id", SimpleColumnType::Integer)], + &["id"], + ); + let group_tags = table_with_fk_constraints( + "group_tags", + vec![ + simple("group_id", SimpleColumnType::Integer), + simple("tag_id", SimpleColumnType::Integer), + ], + &["group_id", "tag_id"], + vec![ + (vec!["group_id"], "groups", vec!["id"]), + (vec!["tag_id"], "tags", vec!["id"]), + ], + ); + ( + user.clone(), + vec![user, user_friends, groups, tags, group_tags], + ) + } + // The unique-FK side of a one-to-one, rendered as the focus table. "one_to_one_source" => { let (_, schema) = reverse_user_schema("profile", &["user_id"], true); (schema[1].clone(), schema) diff --git a/crates/vespertide-exporter/src/tests/mod.rs b/crates/vespertide-exporter/src/tests/mod.rs index b7b6f901..0a78aa1d 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 seven +/// `orm_cases!(multi ...)` arm renders a `Vec` schema for all eight /// 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 six. +/// `String`-returning shape of the other seven. fn render_schema(orm: Orm, schema: &[TableDef]) -> Result { match orm { Orm::SeaOrm => crate::seaorm::export(schema), @@ -36,6 +36,7 @@ fn render_schema(orm: Orm, schema: &[TableDef]) -> Result { Orm::Prisma => crate::prisma::export(schema), Orm::Drizzle => crate::drizzle::export(schema), Orm::Gorm => crate::gorm::export(schema), + Orm::Django => crate::django::export(schema), } } @@ -51,6 +52,7 @@ macro_rules! orm_cases { #[case::prisma(Orm::Prisma)] #[case::drizzle(Orm::Drizzle)] #[case::gorm(Orm::Gorm)] + #[case::django(Orm::Django)] fn $test_name(#[case] orm: Orm) { let table = $fixture(); let rendered = render_entity(orm, &table).unwrap(); @@ -70,6 +72,7 @@ macro_rules! orm_cases { #[case::prisma(Orm::Prisma)] #[case::drizzle(Orm::Drizzle)] #[case::gorm(Orm::Gorm)] + #[case::django(Orm::Django)] fn $test_name(#[case] orm: Orm) { let schema: Vec = $fixture(); let rendered = render_schema(orm, &schema).unwrap(); @@ -331,8 +334,9 @@ orm_cases!( // (`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. +// keep it as a `ForeignKeyConstraint`, Django emits a `# composite foreign key:` +// comment, and JPA currently drops it, so the eight outputs disagree in a way +// worth pinning. orm_cases!( multi composite_fk_relation_snapshot, "composite_fk_relation", @@ -397,11 +401,21 @@ orm_cases!( "relation_field_names", fixtures::relation_field_names ); +orm_cases!( + multi junction_over_composite_key_snapshot, + "junction_over_composite_key", + fixtures::junction_over_composite_key +); orm_cases!( multi enum_name_shared_across_tables_snapshot, "enum_name_shared_across_tables", fixtures::enum_name_shared_across_tables ); +orm_cases!( + python_reserved_names_snapshot, + "python_reserved_names", + fixtures::python_reserved_names +); /// Dispatch the per-ORM `to_pascal_case` helper from a single entry point so /// the cross-ORM consolidation test can exercise every implementation without @@ -414,20 +428,20 @@ 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), + Orm::Gorm | Orm::Django => 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 -/// seven ORM implementations agree. +/// eight 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 four ORMs leave it +/// (the latter two via `vespertide_naming`), the other five ORMs leave it /// intact (their splits operate on `_` only). /// * Non-ASCII characters: `SeaORM`, Prisma and Drizzle use -/// `to_ascii_uppercase`, the other four use `to_uppercase` (Unicode-aware). +/// `to_ascii_uppercase`, the other five use `to_uppercase` (Unicode-aware). /// These divergences are exercised in each backend's own test module where /// applicable. #[rstest] @@ -438,6 +452,7 @@ fn to_pascal_case_for(orm: Orm, s: &str) -> String { #[case::prisma(Orm::Prisma)] #[case::drizzle(Orm::Drizzle)] #[case::gorm(Orm::Gorm)] +#[case::django(Orm::Django)] fn to_pascal_case_shared_semantics( #[values( ("", ""), @@ -469,6 +484,7 @@ fn to_pascal_case_shared_semantics( #[case::prisma(Orm::Prisma)] #[case::drizzle(Orm::Drizzle)] #[case::gorm(Orm::Gorm)] +#[case::django(Orm::Django)] fn render_entity_with_schema_snapshots( #[values( "many_to_many_article", @@ -487,7 +503,9 @@ fn render_entity_with_schema_snapshots( "triple_reverse_relations", "multiple_has_one_relations", "one_to_one_source", - "one_to_one_shared_primary_key" + "one_to_one_shared_primary_key", + "many_to_many_reserved_names", + "many_to_many_uninvolved" )] scenario: &str, #[case] orm: Orm, diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__all_simple_types_snapshot@all_simple_types_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__all_simple_types_snapshot@all_simple_types_Django.snap new file mode 100644 index 00000000..d5da780e --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__all_simple_types_snapshot@all_simple_types_Django.snap @@ -0,0 +1,33 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class AllTypes(models.Model): + id = models.IntegerField(primary_key=True) + small = models.SmallIntegerField() + big = models.BigIntegerField() + real_num = models.FloatField() + double_num = models.FloatField() + text_col = models.TextField() + bool_col = models.BooleanField() + date_col = models.DateField() + time_col = models.TimeField() + ts_col = models.DateTimeField() + tstz_col = models.DateTimeField() + interval_col = models.DurationField() + bytea_col = models.BinaryField() + uuid_col = models.UUIDField() + json_col = models.JSONField() + inet_col = models.GenericIPAddressField() + cidr_col = models.GenericIPAddressField() + macaddr_col = models.CharField(max_length=17) + xml_col = models.TextField() + + class Meta: + managed = False + db_table = "all_types" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_single_pk_snapshot@basic_single_pk_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_single_pk_snapshot@basic_single_pk_Django.snap new file mode 100644 index 00000000..7cbe13e2 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_single_pk_snapshot@basic_single_pk_Django.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Users(models.Model): + id = models.IntegerField(primary_key=True) + display_name = models.TextField(null=True, blank=True) + + class Meta: + managed = False + db_table = "users" diff --git a/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__basic_table.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_table_with_description_snapshot@basic_table_with_description_Django.snap similarity index 53% rename from crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__basic_table.snap rename to crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_table_with_description_snapshot@basic_table_with_description_Django.snap index 47063e83..1c5e86a7 100644 --- a/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__basic_table.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_table_with_description_snapshot@basic_table_with_description_Django.snap @@ -1,7 +1,6 @@ --- -source: crates/vespertide-exporter/src/django/mod.rs -assertion_line: 106 -expression: render_entity(&table).unwrap() +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered --- from __future__ import annotations @@ -9,11 +8,14 @@ from django.db import models class Users(models.Model): - """User accounts""" + """User accounts table""" + # Primary key id = models.AutoField(primary_key=True) - email = models.CharField(max_length=255, unique=True) + # User email address + email = models.TextField(unique=True) name = models.TextField(null=True, blank=True) class Meta: + managed = False db_table = "users" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__binding_collisions_snapshot@binding_collisions_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__binding_collisions_snapshot@binding_collisions_Django.snap new file mode 100644 index 00000000..98d48c1b --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__binding_collisions_snapshot@binding_collisions_Django.snap @@ -0,0 +1,39 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class UserRelations(models.Model): + id = models.IntegerField(primary_key=True) + kind = models.TextField() + + class Meta: + managed = False + db_table = "user_relations" + +class User(models.Model): + id = models.IntegerField(primary_key=True) + + class Meta: + managed = False + db_table = "user" + +class Sql(models.Model): + id = models.IntegerField(primary_key=True) + amount = models.IntegerField() + + class Meta: + managed = False + db_table = "sql" + +class Posts(models.Model): + id = models.IntegerField(primary_key=True) + user = models.ForeignKey("User", on_delete=models.RESTRICT, related_name="+") + + class Meta: + managed = False + db_table = "posts" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__complex_types_snapshot@complex_types_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__complex_types_snapshot@complex_types_Django.snap new file mode 100644 index 00000000..30d0cfdb --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__complex_types_snapshot@complex_types_Django.snap @@ -0,0 +1,19 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class ComplexTypes(models.Model): + id = models.IntegerField(primary_key=True) + varchar_col = models.CharField(max_length=100) + char_col = models.CharField(max_length=10) + numeric_col = models.DecimalField(max_digits=10, decimal_places=2) + custom_col = models.TextField() + + class Meta: + managed = False + db_table = "complex_types" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_constraints_snapshot@composite_constraints_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_constraints_snapshot@composite_constraints_Django.snap new file mode 100644 index 00000000..572e36b4 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_constraints_snapshot@composite_constraints_Django.snap @@ -0,0 +1,24 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class OrderItems(models.Model): + pk = models.CompositePrimaryKey("order_id", "product_id") + order = models.ForeignKey("Orders", on_delete=models.RESTRICT, related_name="+") + product = models.ForeignKey("Products", on_delete=models.RESTRICT, related_name="+") + quantity = models.IntegerField() + + class Meta: + managed = False + db_table = "order_items" + indexes = [ + models.Index(fields=["order_id"]), + ] + constraints = [ + models.UniqueConstraint(fields=["order_id", "product_id"], name="uq_order_items__uq_order_items__order_product"), + ] diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_fk_relation_snapshot@composite_fk_relation_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_fk_relation_snapshot@composite_fk_relation_Django.snap new file mode 100644 index 00000000..69f23c7d --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_fk_relation_snapshot@composite_fk_relation_Django.snap @@ -0,0 +1,28 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Orders(models.Model): + pk = models.CompositePrimaryKey("id", "version") + id = models.IntegerField() + version = models.IntegerField() + + class Meta: + managed = False + db_table = "orders" + +class LineItems(models.Model): + id = models.IntegerField(primary_key=True) + order_id = models.IntegerField() + order_version = models.IntegerField() + sku = models.TextField() + # composite foreign key: (order_id, order_version) -> orders(id, version) + + class Meta: + managed = False + db_table = "line_items" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_index_snapshot@composite_index_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_index_snapshot@composite_index_Django.snap new file mode 100644 index 00000000..55908f0a --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_index_snapshot@composite_index_Django.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class CompositeIndex(models.Model): + id = models.IntegerField(primary_key=True) + tenant_id = models.IntegerField() + name = models.TextField() + + class Meta: + managed = False + db_table = "composite_index" + indexes = [ + models.Index(fields=["tenant_id", "name"]), + ] diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_pk_snapshot@composite_pk_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_pk_snapshot@composite_pk_Django.snap new file mode 100644 index 00000000..78532020 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_pk_snapshot@composite_pk_Django.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Accounts(models.Model): + pk = models.CompositePrimaryKey("id", "tenant_id") + id = models.IntegerField() + tenant_id = models.BigIntegerField() + + class Meta: + managed = False + db_table = "accounts" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_primary_key_snapshot@composite_primary_key_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_primary_key_snapshot@composite_primary_key_Django.snap new file mode 100644 index 00000000..06cbb69f --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_primary_key_snapshot@composite_primary_key_Django.snap @@ -0,0 +1,18 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Membership(models.Model): + pk = models.CompositePrimaryKey("tenant_id", "user_id") + tenant_id = models.IntegerField() + user_id = models.IntegerField() + role = models.TextField() + + class Meta: + managed = False + db_table = "membership" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_constraint_snapshot@composite_unique_constraint_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_constraint_snapshot@composite_unique_constraint_Django.snap new file mode 100644 index 00000000..a2801dff --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_constraint_snapshot@composite_unique_constraint_Django.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class AccountAliases(models.Model): + id = models.IntegerField(primary_key=True) + tenant_id = models.IntegerField() + slug = models.TextField() + + class Meta: + managed = False + db_table = "account_aliases" + constraints = [ + models.UniqueConstraint(fields=["tenant_id", "slug"], name="uq_account_aliases__uq_account_aliases__tenant_slug"), + ] diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_snapshot@composite_unique_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_snapshot@composite_unique_Django.snap new file mode 100644 index 00000000..9f5a0fc6 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_snapshot@composite_unique_Django.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class CompositeUnique(models.Model): + id = models.IntegerField(primary_key=True) + tenant_id = models.IntegerField() + name = models.TextField() + + class Meta: + managed = False + db_table = "composite_unique" + constraints = [ + models.UniqueConstraint(fields=["tenant_id", "name"], name="uq_composite_unique__uq_tenant_name"), + ] diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__defaults_snapshot@defaults_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__defaults_snapshot@defaults_Django.snap new file mode 100644 index 00000000..ce4a381a --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__defaults_snapshot@defaults_Django.snap @@ -0,0 +1,18 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Articles(models.Model): + id = models.AutoField(primary_key=True) + published = models.BooleanField(default=False) + view_count = models.IntegerField(default=0) + status = models.TextField(default="draft") + + class Meta: + managed = False + db_table = "articles" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_multiple_columns_snapshot@enum_multiple_columns_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_multiple_columns_snapshot@enum_multiple_columns_Django.snap new file mode 100644 index 00000000..b11532e4 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_multiple_columns_snapshot@enum_multiple_columns_Django.snap @@ -0,0 +1,27 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class ProductCategory(models.TextChoices): + ELECTRONICS = "electronics" + CLOTHING = "clothing" + FOOD = "food" + +class AvailabilityStatus(models.TextChoices): + IN_STOCK = "in_stock" + OUT_OF_STOCK = "out_of_stock" + PRE_ORDER = "pre_order" + +class Products(models.Model): + id = models.IntegerField() + category = models.CharField(max_length=11, choices=ProductCategory.choices) + availability = models.CharField(max_length=12, choices=AvailabilityStatus.choices) + + class Meta: + managed = False + db_table = "products" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_Django.snap new file mode 100644 index 00000000..f5e4e8b9 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_name_shared_across_tables_snapshot@enum_name_shared_across_tables_Django.snap @@ -0,0 +1,32 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class OrdersStatus(models.TextChoices): + PENDING = "pending" + SHIPPED = "shipped" + +class Orders(models.Model): + id = models.IntegerField(primary_key=True) + status = models.CharField(max_length=7, choices=OrdersStatus.choices) + + class Meta: + managed = False + db_table = "orders" + +class TasksStatus(models.TextChoices): + TODO = "todo" + DONE = "done" + +class Tasks(models.Model): + id = models.IntegerField(primary_key=True) + status = models.CharField(max_length=4, choices=TasksStatus.choices) + + class Meta: + managed = False + db_table = "tasks" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_shared_snapshot@enum_shared_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_shared_snapshot@enum_shared_Django.snap new file mode 100644 index 00000000..33466094 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_shared_snapshot@enum_shared_Django.snap @@ -0,0 +1,22 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class DocStatus(models.TextChoices): + DRAFT = "draft" + PUBLISHED = "published" + ARCHIVED = "archived" + +class Documents(models.Model): + id = models.IntegerField() + status = models.CharField(max_length=9, choices=DocStatus.choices) + review_status = models.CharField(max_length=9, choices=DocStatus.choices, null=True, blank=True) + + class Meta: + managed = False + db_table = "documents" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_special_values_snapshot@enum_special_values_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_special_values_snapshot@enum_special_values_Django.snap new file mode 100644 index 00000000..bc2b9dd3 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_special_values_snapshot@enum_special_values_Django.snap @@ -0,0 +1,22 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class EventSeverity(models.TextChoices): + INFO_LEVEL = "info-level" + WARNING_LEVEL = "warning_level" + ERROR_LEVEL = "ERROR_LEVEL" + _1CRITICAL = "1critical" + +class Events(models.Model): + id = models.IntegerField() + severity = models.CharField(max_length=13, choices=EventSeverity.choices) + + class Meta: + managed = False + db_table = "events" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_with_default_snapshot@enum_with_default_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_with_default_snapshot@enum_with_default_Django.snap new file mode 100644 index 00000000..59a18476 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_with_default_snapshot@enum_with_default_Django.snap @@ -0,0 +1,23 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class TaskStatus(models.TextChoices): + PENDING = "pending" + IN_PROGRESS = "in_progress" + COMPLETED = "completed" + +class Tasks(models.Model): + id = models.IntegerField() + status = models.CharField(max_length=11, choices=TaskStatus.choices, default="pending") + priority = models.IntegerField(default=0) + is_archived = models.BooleanField(default=False) + + class Meta: + managed = False + db_table = "tasks" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__false_boolean_default_snapshot@false_boolean_default_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__false_boolean_default_snapshot@false_boolean_default_Django.snap new file mode 100644 index 00000000..891ab8a9 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__false_boolean_default_snapshot@false_boolean_default_Django.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class BoolDefaults(models.Model): + id = models.IntegerField(primary_key=True) + is_deleted = models.BooleanField(default=False) + + class Meta: + managed = False + db_table = "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_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_names_collide_after_id_strip_snapshot@fk_names_collide_after_id_strip_Django.snap new file mode 100644 index 00000000..bb3f8097 --- /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_Django.snap @@ -0,0 +1,25 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Target(models.Model): + id = models.IntegerField(primary_key=True) + alt = models.IntegerField(unique=True) + + class Meta: + managed = False + db_table = "target" + +class Src(models.Model): + pk_field = models.IntegerField(db_column="pk", primary_key=True) + a = models.ForeignKey("Target", on_delete=models.RESTRICT, related_name="+", null=True, blank=True) + a2 = models.ForeignKey("Target", on_delete=models.RESTRICT, to_field="alt", db_column="a", related_name="+", null=True, blank=True) + + class Meta: + managed = False + db_table = "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_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_with_comment_and_auto_increment_snapshot@fk_with_comment_and_auto_increment_Django.snap new file mode 100644 index 00000000..394c4f2f --- /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_Django.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Child(models.Model): + # References parent table + parent = models.OneToOneField("Parent", on_delete=models.RESTRICT, primary_key=True, related_name="+") + value = models.TextField() + + class Meta: + managed = False + db_table = "child" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__inline_pk_snapshot@inline_pk_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__inline_pk_snapshot@inline_pk_Django.snap new file mode 100644 index 00000000..4e3df777 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__inline_pk_snapshot@inline_pk_Django.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +import uuid +from django.db import models + + +class Users(models.Model): + id = models.UUIDField(default=uuid.uuid4) + email = models.TextField() + + class Meta: + managed = False + db_table = "users" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_all_variant_types_snapshot@integer_enum_all_variant_types_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_all_variant_types_snapshot@integer_enum_all_variant_types_Django.snap new file mode 100644 index 00000000..8738e1bf --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_all_variant_types_snapshot@integer_enum_all_variant_types_Django.snap @@ -0,0 +1,22 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class EdgeState(models.IntegerChoices): + UNKNOWN = -1 + NOT_STARTED = 0 + IN_PROGRESS = 10 + HTTP_500 = 500 + +class WorkflowRuns(models.Model): + id = models.IntegerField(primary_key=True) + state = models.IntegerField(choices=EdgeState.choices) + + class Meta: + managed = False + db_table = "workflow_runs" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_default_snapshot@integer_enum_with_default_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_default_snapshot@integer_enum_with_default_Django.snap new file mode 100644 index 00000000..e54f4909 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_default_snapshot@integer_enum_with_default_Django.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class TaskStatus(models.IntegerChoices): + PENDING = 0 + COMPLETED = 100 + +class Tasks(models.Model): + id = models.IntegerField() + status = models.IntegerField(choices=TaskStatus.choices, default=1) + + class Meta: + managed = False + db_table = "tasks" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_variant_default_snapshot@integer_enum_with_variant_default_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_variant_default_snapshot@integer_enum_with_variant_default_Django.snap new file mode 100644 index 00000000..bdc0c413 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_variant_default_snapshot@integer_enum_with_variant_default_Django.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class TaskRunStatus(models.IntegerChoices): + PENDING = 0 + COMPLETED = 100 + +class TaskRuns(models.Model): + id = models.IntegerField() + status = models.IntegerField(choices=TaskRunStatus.choices) + + class Meta: + managed = False + db_table = "task_runs" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__json_default_snapshot@json_default_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__json_default_snapshot@json_default_Django.snap new file mode 100644 index 00000000..5ad4bd22 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__json_default_snapshot@json_default_Django.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Configs(models.Model): + id = models.IntegerField(primary_key=True) + data = models.JSONField() + + class Meta: + managed = False + db_table = "configs" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__jsonb_custom_type_snapshot@jsonb_custom_type_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__jsonb_custom_type_snapshot@jsonb_custom_type_Django.snap new file mode 100644 index 00000000..bc483096 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__jsonb_custom_type_snapshot@jsonb_custom_type_Django.snap @@ -0,0 +1,18 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class JsonStruct(models.Model): + id = models.IntegerField() + json_data = models.JSONField() + jsonb_data = models.JSONField() + jsonb_nullable = models.JSONField(null=True, blank=True) + + class Meta: + managed = False + db_table = "json_struct" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_Django.snap new file mode 100644 index 00000000..273e4222 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_Django.snap @@ -0,0 +1,35 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class User(models.Model): + id = models.IntegerField(primary_key=True) + + class Meta: + managed = False + db_table = "user" + +class Article(models.Model): + pk = models.CompositePrimaryKey("media_id", "id") + media_id = models.IntegerField() + id = models.IntegerField() + + class Meta: + managed = False + db_table = "article" + +class ArticleUser(models.Model): + pk = models.CompositePrimaryKey("media_id", "article_id", "user_id") + media_id = models.IntegerField() + article_id = models.IntegerField() + user = models.ForeignKey("User", on_delete=models.RESTRICT, related_name="+") + # composite foreign key: (media_id, article_id) -> article(media_id, id) + + class Meta: + managed = False + db_table = "article_user" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_Drizzle_pg.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_Drizzle_pg.snap new file mode 100644 index 00000000..7116b542 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_Drizzle_pg.snap @@ -0,0 +1,37 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +export const user = pgTable("user", { + id: integer("id").primaryKey(), +}); + +export const userRelations = relations(user, ({ one, many }) => ({ + articleUser: many(articleUser), +})); + +export const article = pgTable("article", { + mediaId: integer("media_id").notNull(), + id: integer("id").notNull(), +}, (t) => [ + primaryKey({ name: "article_pkey", columns: [t.mediaId, t.id] }), +]); + +export const articleRelations = relations(article, ({ one, many }) => ({ + articleUser: many(articleUser), +})); + +export const articleUser = pgTable("article_user", { + mediaId: integer("media_id").notNull(), + articleId: integer("article_id").notNull(), + userId: integer("user_id").notNull(), +}, (t) => [ + primaryKey({ name: "article_user_pkey", columns: [t.mediaId, t.articleId, t.userId] }), + foreignKey({ columns: [t.mediaId, t.articleId], foreignColumns: [article.mediaId, article.id], name: "fk_article_user__article_id_media_id" }), + foreignKey({ columns: [t.userId], foreignColumns: [user.id], name: "fk_article_user__user_id" }), +]); + +export const articleUserRelations = relations(articleUser, ({ one, many }) => ({ + mediaArticle: one(article, { fields: [articleUser.mediaId, articleUser.articleId], references: [article.mediaId, article.id] }), + user: one(user, { fields: [articleUser.userId], references: [user.id] }), +})); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_Gorm.snap new file mode 100644 index 00000000..eebf0d95 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_Gorm.snap @@ -0,0 +1,30 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type User struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + ArticleUsers []ArticleUser `gorm:"foreignKey:UserID" json:"-"` +} + +func (User) TableName() string { return "user" } + +type Article struct { + MediaID int32 `gorm:"column:media_id;primaryKey" json:"media_id"` + ID int32 `gorm:"column:id;primaryKey" json:"id"` + ArticleUsers []ArticleUser `gorm:"foreignKey:MediaID,ArticleID;references:MediaID,ID" json:"-"` +} + +func (Article) TableName() string { return "article" } + +type ArticleUser struct { + MediaID int32 `gorm:"column:media_id;primaryKey" json:"media_id"` + ArticleID int32 `gorm:"column:article_id;primaryKey" json:"article_id"` + UserID int32 `gorm:"column:user_id;primaryKey" json:"user_id"` + User *User `gorm:"foreignKey:UserID" json:"-"` + Article *Article `gorm:"foreignKey:MediaID,ArticleID;references:MediaID,ID" json:"-"` +} + +func (ArticleUser) TableName() string { return "article_user" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_Jpa.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_Jpa.snap new file mode 100644 index 00000000..5d2453ee --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_Jpa.snap @@ -0,0 +1,58 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +import jakarta.persistence.*; + +@Entity +@Table(name = "user") +public class User { + + @Id + @Column(name = "id") + private Integer id; + + protected User() { + } +} + +import jakarta.persistence.*; + +@Entity +@Table(name = "article") +public class Article { + + @Id + @Column(name = "media_id") + private Integer mediaId; + + @Id + @Column(name = "id") + private Integer id; + + protected Article() { + } +} + +import jakarta.persistence.*; + +@Entity +@Table(name = "article_user") +public class ArticleUser { + + @Id + @Column(name = "media_id") + private Integer mediaId; + + @Id + @Column(name = "article_id") + private Integer articleId; + + @Id + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + protected ArticleUser() { + } +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_Prisma.snap new file mode 100644 index 00000000..4e823aac --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_Prisma.snap @@ -0,0 +1,30 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model User { + id Int @id + article_user ArticleUser[] + + @@map("user") +} + +model Article { + media_id Int + id Int + article_user ArticleUser[] + + @@id([media_id, id]) + @@map("article") +} + +model ArticleUser { + media_id Int + article_id Int + user_id Int + user User @relation(fields: [user_id], references: [id], onDelete: NoAction, onUpdate: NoAction) + article Article @relation(fields: [media_id, article_id], references: [media_id, id], onDelete: NoAction, onUpdate: NoAction) + + @@id([media_id, article_id, user_id]) + @@map("article_user") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_SeaOrm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_SeaOrm.snap new file mode 100644 index 00000000..04018f70 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_SeaOrm.snap @@ -0,0 +1,62 @@ +--- +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: i32, + #[sea_orm(has_many)] + pub article_users: HasMany, + #[sea_orm(has_many, via = "article_user")] + pub articles_via_article_user: HasMany, +} + +vespera::schema_type!(Schema from Model, name = "UserSchema"); +impl ActiveModelBehavior for ActiveModel {} + + +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "article")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub media_id: i32, + #[sea_orm(primary_key, auto_increment = false)] + pub id: i32, + #[sea_orm(has_many)] + pub article_users: HasMany, + #[sea_orm(has_many, via = "article_user")] + pub users_via_article_user: HasMany, +} + +vespera::schema_type!(Schema from Model, name = "ArticleSchema"); +impl ActiveModelBehavior for ActiveModel {} + + +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "article_user")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub media_id: i32, + #[sea_orm(primary_key, auto_increment = false)] + pub article_id: i32, + #[sea_orm(primary_key, auto_increment = false)] + pub user_id: i32, + #[sea_orm(belongs_to, from = "(media_id, article_id)", to = "(media_id, id)")] + pub article: HasOne, + #[sea_orm(belongs_to, from = "user_id", to = "id")] + pub user: HasOne, +} + +vespera::schema_type!(Schema from Model, name = "ArticleUserSchema"); +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_SqlAlchemy.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_SqlAlchemy.snap new file mode 100644 index 00000000..51e91100 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_SqlAlchemy.snap @@ -0,0 +1,32 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + + +from sqlalchemy import ForeignKey, ForeignKeyConstraint, Integer +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class User(DeclarativeBase): + __tablename__ = "user" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + +class Article(DeclarativeBase): + __tablename__ = "article" + + media_id: Mapped[int] = mapped_column(Integer, primary_key=True) + id: Mapped[int] = mapped_column(Integer, primary_key=True) + +class ArticleUser(DeclarativeBase): + __tablename__ = "article_user" + + media_id: Mapped[int] = mapped_column(Integer, primary_key=True) + article_id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column(Integer, ForeignKey("user.id"), primary_key=True) + + __table_args__ = ( + ForeignKeyConstraint(["media_id", "article_id"], ["article.media_id", "article.id"]), + ) diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_SqlModel.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_SqlModel.snap new file mode 100644 index 00000000..a0ec1a3f --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__junction_over_composite_key_snapshot@junction_over_composite_key_SqlModel.snap @@ -0,0 +1,32 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + + +from sqlmodel import Field, SQLModel +from sqlalchemy import ForeignKeyConstraint + + +class User(SQLModel, table=True): + __tablename__ = "user" + + id: int = Field(primary_key=True) + +class Article(SQLModel, table=True): + __tablename__ = "article" + + media_id: int = Field(primary_key=True) + id: int = Field(primary_key=True) + +class ArticleUser(SQLModel, table=True): + __tablename__ = "article_user" + + media_id: int = Field(primary_key=True) + article_id: int = Field(primary_key=True) + user_id: int = Field(primary_key=True, foreign_key="user.id") + + __table_args__ = ( + ForeignKeyConstraint(["media_id", "article_id"], ["article.media_id", "article.id"]), + ) diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__no_description_snapshot@no_description_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__no_description_snapshot@no_description_Django.snap new file mode 100644 index 00000000..d44428bf --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__no_description_snapshot@no_description_Django.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class NoDesc(models.Model): + id = models.IntegerField(primary_key=True) + + class Meta: + managed = False + db_table = "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_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_names_in_constraints_snapshot@non_identifier_names_in_constraints_Django.snap new file mode 100644 index 00000000..112cf22b --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_names_in_constraints_snapshot@non_identifier_names_in_constraints_Django.snap @@ -0,0 +1,25 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Membership(models.Model): + pk = models.CompositePrimaryKey("_1tenant_id", "_2user_id") + _1tenant_id = models.IntegerField(db_column="1tenant_id") + _2user_id = models.IntegerField(db_column="2user_id") + user_email = models.TextField(db_column="user-email") + _3created = models.TextField(db_column="3created") + + class Meta: + managed = False + db_table = "membership" + indexes = [ + models.Index(fields=["_3created"], name="ix_membership__3created"), + ] + constraints = [ + models.UniqueConstraint(fields=["user_email", "_1tenant_id"], name="uq_membership__1tenant_id_user-email"), + ] diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_names_snapshot@non_identifier_names_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_names_snapshot@non_identifier_names_Django.snap new file mode 100644 index 00000000..bfcd9230 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_names_snapshot@non_identifier_names_Django.snap @@ -0,0 +1,18 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class x1users(models.Model): + id = models.IntegerField(primary_key=True) + _1st_place = models.IntegerField(db_column="1st_place", null=True, blank=True) + user_id = models.TextField(db_column="user-id", null=True, blank=True) + _1st_owner = models.ForeignKey("x1users", on_delete=models.RESTRICT, db_column="1st_owner_id", related_name="+", null=True, blank=True) + + class Meta: + managed = False + db_table = "1users" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_relation_names_snapshot@non_identifier_relation_names_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_relation_names_snapshot@non_identifier_relation_names_Django.snap new file mode 100644 index 00000000..2e2b522b --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_relation_names_snapshot@non_identifier_relation_names_Django.snap @@ -0,0 +1,25 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class x1users(models.Model): + _1id = models.IntegerField(db_column="1id", primary_key=True) + email = models.TextField() + + class Meta: + managed = False + db_table = "1users" + +class Posts(models.Model): + id = models.IntegerField(primary_key=True) + _1st_owner = models.ForeignKey("x1users", on_delete=models.RESTRICT, db_column="1st_owner_id", related_name="+", null=True, blank=True) + _2nd_owner = models.ForeignKey("x1users", on_delete=models.RESTRICT, db_column="2nd_owner_id", related_name="+", null=True, blank=True) + + class Meta: + managed = False + db_table = "posts" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_columns_snapshot@nullable_columns_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_columns_snapshot@nullable_columns_Django.snap new file mode 100644 index 00000000..07b5abc5 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_columns_snapshot@nullable_columns_Django.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Profiles(models.Model): + id = models.AutoField(primary_key=True) + bio = models.TextField(null=True, blank=True) + avatar_url = models.CharField(max_length=500, null=True, blank=True) + + class Meta: + managed = False + db_table = "profiles" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_enum_snapshot@nullable_enum_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_enum_snapshot@nullable_enum_Django.snap new file mode 100644 index 00000000..3c3439b1 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_enum_snapshot@nullable_enum_Django.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class StatusType(models.TextChoices): + ACTIVE = "active" + INACTIVE = "inactive" + +class NullableEnum(models.Model): + id = models.IntegerField(primary_key=True) + status = models.CharField(max_length=8, choices=StatusType.choices, null=True, blank=True) + + class Meta: + managed = False + db_table = "nullable_enum" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__numeric_default_value_snapshot@numeric_default_value_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__numeric_default_value_snapshot@numeric_default_value_Django.snap new file mode 100644 index 00000000..a44cdbda --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__numeric_default_value_snapshot@numeric_default_value_Django.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Products(models.Model): + id = models.IntegerField() + price = models.DecimalField(max_digits=10, decimal_places=2, default=0) + + class Meta: + managed = False + db_table = "products" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__pk_and_fk_together_snapshot@pk_and_fk_together_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__pk_and_fk_together_snapshot@pk_and_fk_together_Django.snap new file mode 100644 index 00000000..9e55add3 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__pk_and_fk_together_snapshot@pk_and_fk_together_Django.snap @@ -0,0 +1,26 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.utils import timezone +from django.db import models + + +class ArticleUser(models.Model): + pk = models.CompositePrimaryKey("article_id", "user_id") + article = models.ForeignKey("Article", on_delete=models.CASCADE, related_name="+") + user = models.ForeignKey("User", on_delete=models.CASCADE, related_name="+") + author_order = models.IntegerField(default=1) + role = models.CharField(max_length=20, default="contributor") + is_lead = models.BooleanField(default=False) + created_at = models.DateTimeField(default=timezone.now) + + class Meta: + managed = False + db_table = "article_user" + indexes = [ + models.Index(fields=["article_id"], name="ix_article_user__article_id"), + models.Index(fields=["user_id"], name="ix_article_user__user_id"), + ] diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_Django.snap new file mode 100644 index 00000000..4fd67ca1 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_Django.snap @@ -0,0 +1,22 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class EventLog(models.Model): + id = models.IntegerField(primary_key=True) + from_field = models.TextField(db_column="from") + def_field = models.TextField(db_column="def") + pk_field = models.IntegerField(db_column="pk") + user_name = models.TextField(db_column="user__name") + total_field = models.IntegerField(db_column="total_") + a_b = models.TextField(db_column="a--b") + pass_field = models.ForeignKey("Targets", on_delete=models.RESTRICT, db_column="pass_id", related_name="+") + + class Meta: + managed = False + db_table = "event__log" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_Drizzle_pg.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_Drizzle_pg.snap new file mode 100644 index 00000000..1c5f9674 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_Drizzle_pg.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +export const eventLog = pgTable("event__log", { + id: integer("id").primaryKey(), + from: text("from").notNull(), + def: text("def").notNull(), + pk: integer("pk").notNull(), + userName: text("user__name").notNull(), + total: integer("total_").notNull(), + aB: text("a--b").notNull(), + passId: integer("pass_id").notNull(), +}, (t) => [ + foreignKey({ columns: [t.passId], foreignColumns: [targets.id], name: "fk_event__log__pass_id" }), +]); + +export const eventLogRelations = relations(eventLog, ({ one, many }) => ({ + pass: one(targets, { fields: [eventLog.passId], references: [targets.id] }), +})); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_Gorm.snap new file mode 100644 index 00000000..3726c235 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_Gorm.snap @@ -0,0 +1,19 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type EventLog struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + From string `gorm:"column:from;not null;type:text" json:"from"` + Def string `gorm:"column:def;not null;type:text" json:"def"` + Pk int32 `gorm:"column:pk;not null" json:"pk"` + UserName string `gorm:"column:user__name;not null;type:text" json:"user__name"` + Total int32 `gorm:"column:total_;not null" json:"total_"` + A__b string `gorm:"column:a--b;not null;type:text" json:"a--b"` + PassID int32 `gorm:"column:pass_id;not null" json:"pass_id"` + Pass *Targets `gorm:"foreignKey:PassID" json:"-"` +} + +func (EventLog) TableName() string { return "event__log" } diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_Jpa.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_Jpa.snap new file mode 100644 index 00000000..6ec08eca --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_Jpa.snap @@ -0,0 +1,39 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +import jakarta.persistence.*; + +@Entity +@Table(name = "event__log") +public class EventLog { + + @Id + @Column(name = "id") + private Integer id; + + @Column(name = "from", nullable = false, columnDefinition = "TEXT") + private String from; + + @Column(name = "def", nullable = false, columnDefinition = "TEXT") + private String def; + + @Column(name = "pk", nullable = false) + private Integer pk; + + @Column(name = "user__name", nullable = false, columnDefinition = "TEXT") + private String userName; + + @Column(name = "total_", nullable = false) + private Integer total; + + @Column(name = "a--b", nullable = false, columnDefinition = "TEXT") + private String a__b; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "pass_id", nullable = false) + private Targets pass; + + protected EventLog() { + } +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_Prisma.snap new file mode 100644 index 00000000..60e78c8f --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_Prisma.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model EventLog { + id Int @id + from String + def String + pk Int + user__name String + total_ Int + a__b String @map("a--b") + pass_id Int + pass Targets @relation(fields: [pass_id], references: [id], onDelete: NoAction, onUpdate: NoAction) + + @@map("event__log") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_SeaOrm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_SeaOrm.snap new file mode 100644 index 00000000..ebf8f7d1 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_SeaOrm.snap @@ -0,0 +1,26 @@ +--- +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 = "event__log")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub from: String, + pub def: String, + pub pk: i32, + pub user__name: String, + pub total_: i32, + #[sea_orm(column_name = "a--b")] + pub a__b: String, + pub pass_id: i32, + #[sea_orm(belongs_to, from = "pass_id", to = "id")] + pub pass: HasOne, +} + +vespera::schema_type!(Schema from Model, name = "EventLogSchema"); +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_SqlAlchemy.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_SqlAlchemy.snap new file mode 100644 index 00000000..a9293e2f --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_SqlAlchemy.snap @@ -0,0 +1,22 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + + +from sqlalchemy import ForeignKey, Integer, Text +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class EventLog(DeclarativeBase): + __tablename__ = "event__log" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + from_: Mapped[str] = mapped_column("from", Text, nullable=False) + def_: Mapped[str] = mapped_column("def", Text, nullable=False) + pk: Mapped[int] = mapped_column(Integer, nullable=False) + user__name: Mapped[str] = mapped_column(Text, nullable=False) + total_: Mapped[int] = mapped_column(Integer, nullable=False) + a__b: Mapped[str] = mapped_column("a--b", Text, nullable=False) + pass_id: Mapped[int] = mapped_column(Integer, ForeignKey("targets.id"), nullable=False) diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_SqlModel.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_SqlModel.snap new file mode 100644 index 00000000..6f99db63 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__python_reserved_names_snapshot@python_reserved_names_SqlModel.snap @@ -0,0 +1,21 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + + +from sqlmodel import Field, SQLModel + + +class EventLog(SQLModel, table=True): + __tablename__ = "event__log" + + id: int = Field(primary_key=True) + from_: str = Field(sa_column_kwargs={"name": "from"}) + def_: str = Field(sa_column_kwargs={"name": "def"}) + pk: int = Field(...) + user__name: str = Field(...) + total_: int = Field(...) + a__b: str = Field(sa_column_kwargs={"name": "a--b"}) + pass_id: int = Field(foreign_key="targets.id") diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_Django.snap new file mode 100644 index 00000000..b2b46ed4 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reference_actions_snapshot@reference_actions_Django.snap @@ -0,0 +1,32 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Users(models.Model): + id = models.IntegerField(primary_key=True) + + class Meta: + managed = False + db_table = "users" + +class Posts(models.Model): + id = models.IntegerField(primary_key=True) + user = models.ForeignKey("Users", on_delete=models.CASCADE, related_name="+") + + class Meta: + managed = False + db_table = "posts" + +class Comments(models.Model): + id = models.IntegerField(primary_key=True) + post = models.ForeignKey("Posts", on_delete=models.DO_NOTHING, related_name="+", null=True, blank=True) + author = models.ForeignKey("Users", on_delete=models.SET_DEFAULT, default=1, related_name="+") + + class Meta: + managed = False + db_table = "comments" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_Django.snap new file mode 100644 index 00000000..ed9dc949 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_field_names_snapshot@relation_field_names_Django.snap @@ -0,0 +1,54 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class OrderRegions(models.Model): + pk = models.CompositePrimaryKey("order_id", "region_id") + order_id = models.IntegerField() + region_id = models.IntegerField() + + class Meta: + managed = False + db_table = "order_regions" + +class OrderItems(models.Model): + id = models.IntegerField(primary_key=True) + order_id = models.IntegerField() + region_id = models.IntegerField() + order_regions = models.TextField() + order_regions2 = models.TextField() + # composite foreign key: (order_id, region_id) -> order_regions(order_id, region_id) + + class Meta: + managed = False + db_table = "order_items" + +class Users(models.Model): + id = models.IntegerField(primary_key=True) + posts = models.TextField() + + class Meta: + managed = False + db_table = "users" + +class Posts(models.Model): + id = models.IntegerField(primary_key=True) + user = models.ForeignKey("Users", on_delete=models.RESTRICT, related_name="+") + table_name = models.ForeignKey("Categories", on_delete=models.RESTRICT, related_name="+") + + class Meta: + managed = False + db_table = "posts" + +class Categories(models.Model): + id = models.IntegerField(primary_key=True) + parent = models.ForeignKey("Categories", on_delete=models.RESTRICT, related_name="+", null=True, blank=True) + + class Meta: + managed = False + db_table = "categories" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_name_taken_by_column_snapshot@relation_name_taken_by_column_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_name_taken_by_column_snapshot@relation_name_taken_by_column_Django.snap new file mode 100644 index 00000000..1d81d165 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_name_taken_by_column_snapshot@relation_name_taken_by_column_Django.snap @@ -0,0 +1,23 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Users(models.Model): + id = models.IntegerField(primary_key=True) + + class Meta: + managed = False + db_table = "users" + +class Items(models.Model): + id = models.IntegerField(primary_key=True) + owner = models.ForeignKey("Users", on_delete=models.RESTRICT, db_column="owner", related_name="+", null=True, blank=True) + + class Meta: + managed = False + db_table = "items" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@composite_and_single_fk_same_target_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@composite_and_single_fk_same_target_Django.snap new file mode 100644 index 00000000..da93dbab --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@composite_and_single_fk_same_target_Django.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Src(models.Model): + id = models.IntegerField(primary_key=True) + a_id = models.IntegerField() + b_id = models.IntegerField() + solo = models.IntegerField() + # foreign key: (solo) -> target(u) + # composite foreign key: (a_id, b_id) -> target(a, b) + + class Meta: + managed = False + db_table = "src" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@composite_fk_parent_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@composite_fk_parent_Django.snap new file mode 100644 index 00000000..2d920e69 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@composite_fk_parent_Django.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Parent(models.Model): + pk = models.CompositePrimaryKey("id1", "id2") + id1 = models.IntegerField() + id2 = models.IntegerField() + + class Meta: + managed = False + db_table = "parent" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@dual_reverse_relations_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@dual_reverse_relations_Django.snap new file mode 100644 index 00000000..f2e79fb5 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@dual_reverse_relations_Django.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Dual(models.Model): + username = models.TextField(primary_key=True) + + class Meta: + managed = False + db_table = "dual" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_article_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_article_Django.snap new file mode 100644 index 00000000..487f0ef1 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_article_Django.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Article(models.Model): + id = models.BigIntegerField(primary_key=True) + users = models.ManyToManyField("User", through="ArticleUser", related_name="+") + + class Meta: + managed = False + db_table = "article" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_missing_target_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_missing_target_Django.snap new file mode 100644 index 00000000..c21f34e6 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_missing_target_Django.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Article(models.Model): + id = models.BigIntegerField(primary_key=True) + + class Meta: + managed = False + db_table = "article" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_multiple_junctions_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_multiple_junctions_Django.snap new file mode 100644 index 00000000..e5574d34 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_multiple_junctions_Django.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class User(models.Model): + id = models.UUIDField(primary_key=True) + medias_via_user_media_role = models.ManyToManyField("Media", through="UserMediaRole", related_name="+") + medias_via_user_media_favorite = models.ManyToManyField("Media", through="UserMediaFavorite", related_name="+") + + class Meta: + managed = False + db_table = "user" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_Django.snap new file mode 100644 index 00000000..064ee15a --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_Django.snap @@ -0,0 +1,18 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class User(models.Model): + id = models.IntegerField(primary_key=True) + tags = models.TextField() + tags2 = models.ManyToManyField("Tags", through="UserTags", related_name="+") + pass_field = models.ManyToManyField("Pass", through="User_pass", related_name="+") + + class Meta: + managed = False + db_table = "user" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_Drizzle_pg.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_Drizzle_pg.snap new file mode 100644 index 00000000..4fb461b8 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_Drizzle_pg.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +export const user = pgTable("user", { + id: integer("id").primaryKey(), + tags: text("tags").notNull(), +}); + +export const userRelations = relations(user, ({ one, many }) => ({ + userTags: many(userTags), + userPass: many(userPass), +})); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_Gorm.snap new file mode 100644 index 00000000..b5dfcfb8 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_Gorm.snap @@ -0,0 +1,14 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type User struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + Tags string `gorm:"column:tags;not null;type:text" json:"tags"` + UserTags []UserTags `gorm:"foreignKey:UserID" json:"-"` + User_pass []User_pass `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_reserved_names_Jpa.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_Jpa.snap new file mode 100644 index 00000000..03fbef11 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_Jpa.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +import jakarta.persistence.*; + +@Entity +@Table(name = "user") +public class User { + + @Id + @Column(name = "id") + private Integer id; + + @Column(name = "tags", nullable = false, columnDefinition = "TEXT") + private String tags; + + protected User() { + } +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_Prisma.snap new file mode 100644 index 00000000..1fc5cfeb --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_Prisma.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model User { + id Int @id + tags String + user_tags UserTags[] + user_pass UserPass[] + + @@map("user") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_SeaOrm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_SeaOrm.snap new file mode 100644 index 00000000..afdbdbaf --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_SeaOrm.snap @@ -0,0 +1,25 @@ +--- +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: i32, + pub tags: String, + #[sea_orm(has_many)] + pub user_tags: HasMany, + #[sea_orm(has_many, via = "user_tags")] + pub tags_via_user_tags: HasMany, + #[sea_orm(has_many)] + pub user_pass: HasMany, + #[sea_orm(has_many, via = "user_pass")] + pub pass_via_user_pass: HasMany, +} + +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@many_to_many_reserved_names_SqlAlchemy.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_SqlAlchemy.snap new file mode 100644 index 00000000..c7b5b5f2 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_SqlAlchemy.snap @@ -0,0 +1,16 @@ +--- +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 User(DeclarativeBase): + __tablename__ = "user" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + tags: Mapped[str] = mapped_column(Text, nullable=False) diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_SqlModel.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_SqlModel.snap new file mode 100644 index 00000000..33816b27 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_reserved_names_SqlModel.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + + +from sqlmodel import Field, SQLModel + + +class User(SQLModel, table=True): + __tablename__ = "user" + + id: int = Field(primary_key=True) + tags: str = Field(...) diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_Django.snap new file mode 100644 index 00000000..ca6c93aa --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_Django.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class User(models.Model): + id = models.IntegerField(primary_key=True) + + class Meta: + managed = False + db_table = "user" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_Drizzle_pg.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_Drizzle_pg.snap new file mode 100644 index 00000000..7496d4e2 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_Drizzle_pg.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +export const user = pgTable("user", { + id: integer("id").primaryKey(), +}); + +export const userRelations = relations(user, ({ one, many }) => ({ + userUserFriends: many(userFriends, { relationName: "UserFriendsUser" }), + friendUserFriends: many(userFriends, { relationName: "UserFriendsFriend" }), +})); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_Gorm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_Gorm.snap new file mode 100644 index 00000000..8116ff4e --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_Gorm.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +package models + +type User struct { + ID int32 `gorm:"column:id;primaryKey" json:"id"` + UserFriendsByUserID []UserFriends `gorm:"foreignKey:UserID" json:"-"` + UserFriendsByFriendID []UserFriends `gorm:"foreignKey:FriendID" 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_uninvolved_Jpa.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_Jpa.snap new file mode 100644 index 00000000..d286c7a7 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_Jpa.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +import jakarta.persistence.*; + +@Entity +@Table(name = "user") +public class User { + + @Id + @Column(name = "id") + private Integer id; + + protected User() { + } +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_Prisma.snap new file mode 100644 index 00000000..8230b515 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_Prisma.snap @@ -0,0 +1,11 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +model User { + id Int @id + user_user_friends UserFriends[] @relation("UserFriendsUser") + friend_user_friends UserFriends[] @relation("UserFriendsFriend") + + @@map("user") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_SeaOrm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_SeaOrm.snap new file mode 100644 index 00000000..f9fe3922 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_SeaOrm.snap @@ -0,0 +1,56 @@ +--- +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: i32, + #[sea_orm(has_many, relation_enum = "UserFriends", via_rel = "User")] + pub user_user_friends: HasMany, + #[sea_orm(has_many, relation_enum = "Friend", via_rel = "Friend")] + pub friend_user_friends: HasMany, +} + +vespera::schema_type!(Schema from Model, name = "UserSchema"); +impl ActiveModelBehavior for ActiveModel {} + +pub struct UserIdToFriendIdViaUserFriends; +impl Linked for UserIdToFriendIdViaUserFriends { + type FromEntity = Entity; + type ToEntity = Entity; + + fn link(&self) -> Vec { + vec![ + super::user_friends::Relation::User.def().rev(), + super::user_friends::Relation::Friend.def(), + ] + } +} + +pub struct FriendIdToUserIdViaUserFriends; +impl Linked for FriendIdToUserIdViaUserFriends { + type FromEntity = Entity; + type ToEntity = Entity; + + fn link(&self) -> Vec { + vec![ + super::user_friends::Relation::Friend.def().rev(), + super::user_friends::Relation::User.def(), + ] + } +} + +impl Model { + pub fn find_friend_ids_via_user_friends_from_user_id(&self) -> Select { + self.find_linked(UserIdToFriendIdViaUserFriends) + } + + pub fn find_user_ids_via_user_friends_from_friend_id(&self) -> Select { + self.find_linked(FriendIdToUserIdViaUserFriends) + } +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_SqlAlchemy.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_SqlAlchemy.snap new file mode 100644 index 00000000..c79b8d6a --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_SqlAlchemy.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + + +from sqlalchemy import Integer +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class User(DeclarativeBase): + __tablename__ = "user" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_SqlModel.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_SqlModel.snap new file mode 100644 index 00000000..6b517060 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_uninvolved_SqlModel.snap @@ -0,0 +1,14 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + + +from sqlmodel import Field, SQLModel + + +class User(SQLModel, table=True): + __tablename__ = "user" + + id: int = Field(primary_key=True) diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_user_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_user_Django.snap new file mode 100644 index 00000000..85e61db7 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_user_Django.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class User(models.Model): + id = models.UUIDField(primary_key=True) + articles = models.ManyToManyField("Article", through="ArticleUser", related_name="+") + + class Meta: + managed = False + db_table = "user" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_fk_same_table_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_fk_same_table_Django.snap new file mode 100644 index 00000000..f960596c --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_fk_same_table_Django.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Post(models.Model): + id = models.UUIDField(primary_key=True) + creator_user = models.ForeignKey("User", on_delete=models.RESTRICT, related_name="+") + used_by_user = models.ForeignKey("User", on_delete=models.RESTRICT, related_name="+") + + class Meta: + managed = False + db_table = "post" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_has_one_relations_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_has_one_relations_Django.snap new file mode 100644 index 00000000..319f805d --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_has_one_relations_Django.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class User(models.Model): + id = models.UUIDField(primary_key=True) + + class Meta: + managed = False + db_table = "user" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_reverse_relations_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_reverse_relations_Django.snap new file mode 100644 index 00000000..319f805d --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_reverse_relations_Django.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class User(models.Model): + id = models.UUIDField(primary_key=True) + + class Meta: + managed = False + db_table = "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_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_fk_not_in_pk_another_Django.snap new file mode 100644 index 00000000..a6794f46 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_fk_not_in_pk_another_Django.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Another(models.Model): + id = models.IntegerField(primary_key=True) + + class Meta: + managed = False + db_table = "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_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_fk_not_in_pk_other_Django.snap new file mode 100644 index 00000000..f0e9547c --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_fk_not_in_pk_other_Django.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Other(models.Model): + id = models.IntegerField(primary_key=True) + + class Meta: + managed = False + db_table = "other" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_single_pk_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_single_pk_Django.snap new file mode 100644 index 00000000..f0e9547c --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_single_pk_Django.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Other(models.Model): + id = models.IntegerField(primary_key=True) + + class Meta: + managed = False + db_table = "other" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_Django.snap new file mode 100644 index 00000000..319f805d --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_shared_primary_key_Django.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class User(models.Model): + id = models.UUIDField(primary_key=True) + + class Meta: + managed = False + db_table = "user" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_Django.snap new file mode 100644 index 00000000..f5b64207 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@one_to_one_source_Django.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Profile(models.Model): + id = models.UUIDField(primary_key=True) + user = models.OneToOneField("User", on_delete=models.RESTRICT, related_name="+") + + class Meta: + managed = False + db_table = "profile" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@triple_reverse_relations_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@triple_reverse_relations_Django.snap new file mode 100644 index 00000000..f2e79fb5 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@triple_reverse_relations_Django.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Dual(models.Model): + username = models.TextField(primary_key=True) + + class Meta: + managed = False + db_table = "dual" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@username_fk_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@username_fk_Django.snap new file mode 100644 index 00000000..a0cac5bf --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@username_fk_Django.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Session(models.Model): + id = models.UUIDField(primary_key=True) + username = models.ForeignKey("User", on_delete=models.RESTRICT, db_column="username", related_name="+") + + class Meta: + managed = False + db_table = "session" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reserved_word_identifiers_snapshot@reserved_word_identifiers_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reserved_word_identifiers_snapshot@reserved_word_identifiers_Django.snap new file mode 100644 index 00000000..67fc1cea --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reserved_word_identifiers_snapshot@reserved_word_identifiers_Django.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Order(models.Model): + id = models.IntegerField(primary_key=True) + user = models.TextField() + select = models.IntegerField() + + class Meta: + managed = False + db_table = "order" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__self_referencing_fk_snapshot@self_referencing_fk_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__self_referencing_fk_snapshot@self_referencing_fk_Django.snap new file mode 100644 index 00000000..c8af5e84 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__self_referencing_fk_snapshot@self_referencing_fk_Django.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Employees(models.Model): + id = models.IntegerField(primary_key=True) + manager = models.ForeignKey("Employees", on_delete=models.SET_NULL, related_name="+", null=True, blank=True) + + class Meta: + managed = False + db_table = "employees" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_Django.snap new file mode 100644 index 00000000..65ef9b95 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__semicolon_default_snapshot@semicolon_default_Django.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Notes(models.Model): + id = models.IntegerField(primary_key=True) + note = models.TextField(default="a;b") + body = models.TextField() + + class Meta: + managed = False + db_table = "notes" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_default_and_true_boolean_snapshot@server_default_and_true_boolean_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_default_and_true_boolean_snapshot@server_default_and_true_boolean_Django.snap new file mode 100644 index 00000000..1a1f68e3 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_default_and_true_boolean_snapshot@server_default_and_true_boolean_Django.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.utils import timezone +from django.db import models + + +class Logs(models.Model): + id = models.AutoField(primary_key=True) + active = models.BooleanField(default=True) + created_at = models.DateTimeField(default=timezone.now) + score = models.FloatField(default=1.5) + tag = models.TextField() + + class Meta: + managed = False + db_table = "logs" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_defaults_snapshot@server_defaults_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_defaults_snapshot@server_defaults_Django.snap new file mode 100644 index 00000000..b60031a1 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_defaults_snapshot@server_defaults_Django.snap @@ -0,0 +1,19 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.utils import timezone +from django.db import models + + +class WithDefaults(models.Model): + id = models.IntegerField(primary_key=True) + created_at = models.DateTimeField(default=timezone.now) + status = models.TextField(default="active") + count = models.IntegerField(default=0) + + class Meta: + managed = False + db_table = "with_defaults" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__small_multi_schema_sequential_snapshot@small_multi_schema_sequential_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__small_multi_schema_sequential_snapshot@small_multi_schema_sequential_Django.snap new file mode 100644 index 00000000..e8b19d93 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__small_multi_schema_sequential_snapshot@small_multi_schema_sequential_Django.snap @@ -0,0 +1,25 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Users(models.Model): + id = models.IntegerField(primary_key=True) + display_name = models.TextField(null=True, blank=True) + + class Meta: + managed = False + db_table = "users" + +class Posts(models.Model): + id = models.IntegerField(primary_key=True) + user = models.ForeignKey("Users", on_delete=models.RESTRICT, related_name="+") + title = models.TextField() + + class Meta: + managed = False + db_table = "posts" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__string_default_snapshot@string_default_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__string_default_snapshot@string_default_Django.snap new file mode 100644 index 00000000..2136de4b --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__string_default_snapshot@string_default_Django.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class StringDefaults(models.Model): + id = models.IntegerField(primary_key=True) + status = models.TextField(default="active") + + class Meta: + managed = False + db_table = "string_defaults" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_level_pk_snapshot@table_level_pk_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_level_pk_snapshot@table_level_pk_Django.snap new file mode 100644 index 00000000..ceb1eda6 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_level_pk_snapshot@table_level_pk_Django.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Orders(models.Model): + id = models.UUIDField(primary_key=True) + customer_id = models.UUIDField() + total = models.FloatField() + + class Meta: + managed = False + db_table = "orders" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_check_snapshot@table_with_check_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_check_snapshot@table_with_check_Django.snap new file mode 100644 index 00000000..b93d1de3 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_check_snapshot@table_with_check_Django.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Products(models.Model): + id = models.IntegerField(primary_key=True) + price = models.IntegerField() + + class Meta: + managed = False + db_table = "products" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_composite_fk_snapshot@table_with_composite_fk_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_composite_fk_snapshot@table_with_composite_fk_Django.snap new file mode 100644 index 00000000..daf490d7 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_composite_fk_snapshot@table_with_composite_fk_Django.snap @@ -0,0 +1,19 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class LineItems(models.Model): + id = models.IntegerField(primary_key=True) + order_id = models.IntegerField() + order_version = models.IntegerField() + sku = models.TextField() + # composite foreign key: (order_id, order_version) -> orders(id, version) + + class Meta: + managed = False + db_table = "line_items" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_enum_snapshot@table_with_enum_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_enum_snapshot@table_with_enum_Django.snap new file mode 100644 index 00000000..2ed46d62 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_enum_snapshot@table_with_enum_Django.snap @@ -0,0 +1,21 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class OrderStatus(models.TextChoices): + PENDING = "pending" + SHIPPED = "shipped" + DELIVERED = "delivered" + +class Orders(models.Model): + id = models.IntegerField() + status = models.CharField(max_length=9, choices=OrderStatus.choices) + + class Meta: + managed = False + db_table = "orders" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_fk_snapshot@table_with_fk_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_fk_snapshot@table_with_fk_Django.snap new file mode 100644 index 00000000..c64d3aa3 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_fk_snapshot@table_with_fk_Django.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Posts(models.Model): + id = models.IntegerField(primary_key=True) + user = models.ForeignKey("Users", on_delete=models.RESTRICT, related_name="+") + title = models.TextField() + + class Meta: + managed = False + db_table = "posts" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_indexes_snapshot@table_with_indexes_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_indexes_snapshot@table_with_indexes_Django.snap new file mode 100644 index 00000000..9e5761ea --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_indexes_snapshot@table_with_indexes_Django.snap @@ -0,0 +1,21 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Articles(models.Model): + id = models.IntegerField(primary_key=True) + title = models.TextField() + created_at = models.DateTimeField() + + class Meta: + managed = False + db_table = "articles" + indexes = [ + models.Index(fields=["created_at"]), + models.Index(fields=["title"], name="ix_articles__title"), + ] diff --git a/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__table_with_integer_enum.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_integer_enum_snapshot@table_with_integer_enum_Django.snap similarity index 62% rename from crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__table_with_integer_enum.snap rename to crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_integer_enum_snapshot@table_with_integer_enum_Django.snap index df5d2323..c5a30ae5 100644 --- a/crates/vespertide-exporter/src/django/snapshots/vespertide_exporter__django__tests__table_with_integer_enum.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_integer_enum_snapshot@table_with_integer_enum_Django.snap @@ -1,7 +1,6 @@ --- -source: crates/vespertide-exporter/src/django/mod.rs -assertion_line: 175 -expression: render_entity(&table).unwrap() +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered --- from __future__ import annotations @@ -9,13 +8,14 @@ from django.db import models class PriorityLevel(models.IntegerChoices): - LOW = 0, "low" - MEDIUM = 10, "medium" - HIGH = 20, "high" + LOW = 0 + MEDIUM = 10 + HIGH = 20 class Tasks(models.Model): id = models.IntegerField(primary_key=True) priority = models.IntegerField(choices=PriorityLevel.choices) class Meta: + managed = False db_table = "tasks" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unique_and_indexed_snapshot@unique_and_indexed_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unique_and_indexed_snapshot@unique_and_indexed_Django.snap new file mode 100644 index 00000000..e1590207 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unique_and_indexed_snapshot@unique_and_indexed_Django.snap @@ -0,0 +1,22 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Users(models.Model): + id = models.IntegerField() + email = models.TextField(unique=True) + username = models.TextField(unique=True) + department = models.TextField(null=True, blank=True) + status = models.TextField(default="active") + + class Meta: + managed = False + db_table = "users" + indexes = [ + models.Index(fields=["department"], name="ix_users__idx_department"), + ] diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_constant_default_snapshot@unknown_constant_default_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_constant_default_snapshot@unknown_constant_default_Django.snap new file mode 100644 index 00000000..8911f5e3 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_constant_default_snapshot@unknown_constant_default_Django.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class UnknownDefault(models.Model): + id = models.IntegerField(primary_key=True) + value = models.TextField() + + class Meta: + managed = False + db_table = "unknown_default" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_function_default_snapshot@unknown_function_default_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_function_default_snapshot@unknown_function_default_Django.snap new file mode 100644 index 00000000..b90ebd72 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_function_default_snapshot@unknown_function_default_Django.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class UnknownDefaults(models.Model): + id = models.IntegerField(primary_key=True) + code = models.TextField() + + class Meta: + managed = False + db_table = "unknown_defaults" diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_index_snapshot@unnamed_composite_index_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_index_snapshot@unnamed_composite_index_Django.snap new file mode 100644 index 00000000..3127c056 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_index_snapshot@unnamed_composite_index_Django.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class UnnamedIndex(models.Model): + id = models.IntegerField(primary_key=True) + col_a = models.IntegerField() + col_b = models.IntegerField() + + class Meta: + managed = False + db_table = "unnamed_index" + indexes = [ + models.Index(fields=["col_a", "col_b"], name="ix_unnamed_index__col_a_col_b"), + ] diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_unique_snapshot@unnamed_composite_unique_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_unique_snapshot@unnamed_composite_unique_Django.snap new file mode 100644 index 00000000..2846e265 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_unique_snapshot@unnamed_composite_unique_Django.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class UnnamedUnique(models.Model): + id = models.IntegerField(primary_key=True) + col_a = models.IntegerField() + col_b = models.IntegerField() + + class Meta: + managed = False + db_table = "unnamed_unique" + constraints = [ + models.UniqueConstraint(fields=["col_a", "col_b"], name="uq_unnamed_unique__col_a_col_b"), + ] diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_index_and_unique_snapshot@unnamed_index_and_unique_Django.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_index_and_unique_snapshot@unnamed_index_and_unique_Django.snap new file mode 100644 index 00000000..e82541ca --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_index_and_unique_snapshot@unnamed_index_and_unique_Django.snap @@ -0,0 +1,23 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +expression: rendered +--- +from __future__ import annotations + +from django.db import models + + +class Events(models.Model): + id = models.IntegerField(primary_key=True) + venue_id = models.IntegerField() + date = models.DateField() + + class Meta: + managed = False + db_table = "events" + indexes = [ + models.Index(fields=["venue_id", "date"], name="ix_events__date_venue_id"), + ] + constraints = [ + models.UniqueConstraint(fields=["venue_id", "date"], name="uq_events__date_venue_id"), + ] diff --git a/crates/vespertide-exporter/src/utils/common.rs b/crates/vespertide-exporter/src/utils/common.rs index 2588ee14..3ac072a4 100644 --- a/crates/vespertide-exporter/src/utils/common.rs +++ b/crates/vespertide-exporter/src/utils/common.rs @@ -76,9 +76,9 @@ pub(crate) fn join_qualified_refs(ref_table: &str, ref_cols: &[&str]) -> String /// /// 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. +/// escapes are the ones TypeScript, Go and Python share, so every literal the +/// Drizzle, GORM and Django 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('"'); @@ -131,7 +131,7 @@ pub(crate) fn is_jsonb_custom_type(custom_type: &str) -> bool { /// 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 +/// (SQLAlchemy, SQLModel, Django) 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>, diff --git a/crates/vespertide-exporter/tests/parallel_consolidated.rs b/crates/vespertide-exporter/tests/parallel_consolidated.rs index fb1b174e..0867ee15 100644 --- a/crates/vespertide-exporter/tests/parallel_consolidated.rs +++ b/crates/vespertide-exporter/tests/parallel_consolidated.rs @@ -12,6 +12,7 @@ use vespertide_exporter::Orm; #[case::prisma(Orm::Prisma)] #[case::drizzle(Orm::Drizzle)] #[case::gorm(Orm::Gorm)] +#[case::django(Orm::Django)] fn export_is_byte_identical_across_thread_counts(#[case] orm: Orm) { let schema = large_schema(100); @@ -43,6 +44,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), + Orm::Django => vespertide_exporter::django::export(schema), } } From de9d8a441cdc1428216b85365cc4c2f65d187244 Mon Sep 17 00:00:00 2001 From: JaeHyunAn <98042706+yyuneu@users.noreply.github.com> Date: Sun, 20 Sep 2026 15:40:22 +0900 Subject: [PATCH 11/12] =?UTF-8?q?feat(cli):=20export=20--orm=20django?= =?UTF-8?q?=EB=8A=94=20models.py=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 | 41 +++++++++++-------- .../src/commands/export/tests/django.rs | 25 +++++++++++ .../src/commands/export/tests/mod.rs | 1 + .../src/commands/export/tests/models_file.rs | 22 ++++++---- ...es_the_configured_app_label_into_meta.snap | 18 ++++++++ ...odel_directories_into_one_file@Django.snap | 25 +++++++++++ 6 files changed, 109 insertions(+), 23 deletions(-) create mode 100644 crates/vespertide-cli/src/commands/export/tests/django.rs create mode 100644 crates/vespertide-cli/src/commands/export/tests/snapshots/vespertide_cli__commands__export__tests__django__export_django_writes_the_configured_app_label_into_meta.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@Django.snap diff --git a/crates/vespertide-cli/src/commands/export/mod.rs b/crates/vespertide-cli/src/commands/export/mod.rs index 16ec58a8..09740768 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, gorm::GormExporterWithConfig, prisma, python_naming::to_pascal_case, - render_entity_with_schema, seaorm::SeaOrmExporterWithConfig, + Orm, django::DjangoExporterWithConfig, 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,15 +35,15 @@ pub async fn cmd_export(orm: Orm, export_dir: Option) -> Result<()> { let target_root = resolve_export_dir(export_dir, &config); - // Prisma, Drizzle and GORM use a single-file output strategy + // Prisma, Drizzle, GORM and Django 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; + if matches!(orm, Orm::Gorm | Orm::Django) { + return cmd_export_models_file(orm, &config, normalized_models, target_root).await; } // Clean the export directory before regenerating @@ -472,24 +472,33 @@ async fn cmd_export_drizzle( /// 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. +/// GORM and Django get the whole schema as one `models.go` / `models.py`. 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; Django loads an app's models from its one `models` module. /// -/// 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`]. +/// A fixed file name also leaves nothing to sweep, so the user's own `.go` and +/// `.py` files are never touched. The file itself is only overwritten when it +/// starts with [`GENERATED_MARKER`]: `models.py` is the name `startapp` gives +/// the user's own module. async fn cmd_export_models_file( + orm: Orm, + config: &VespertideConfig, 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 (comment, code) = if matches!(orm, Orm::Gorm) { + let exporter = GormExporterWithConfig::for_export_dir(&target_root); + ("//", exporter.export(&all_tables)) + } else { + let exporter = DjangoExporterWithConfig::new(config.django()); + ("#", exporter.export(&all_tables)) + }; + let code = code.map_err(|e| anyhow::anyhow!(e))?; + let marker = format!("{comment} {GENERATED_MARKER}"); - let out_path = target_root.join("models.go"); + let out_path = target_root.join(format!("models.{}", orm.file_extension())); if let Ok(existing) = fs::read(&out_path).await && !existing.starts_with(marker.as_bytes()) { diff --git a/crates/vespertide-cli/src/commands/export/tests/django.rs b/crates/vespertide-cli/src/commands/export/tests/django.rs new file mode 100644 index 00000000..60ff7c6a --- /dev/null +++ b/crates/vespertide-cli/src/commands/export/tests/django.rs @@ -0,0 +1,25 @@ +use super::*; +use insta::assert_snapshot; + +/// `app_label` is the one `django` config setting, and it only reaches the +/// generated `Meta` class through `DjangoExporterWithConfig`. Exporting with it +/// set is what proves the CLI takes that path. +#[tokio::test] +#[serial] +async fn export_django_writes_the_configured_app_label_into_meta() { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + let mut cfg = serde_json::to_value(VespertideConfig::default()).unwrap(); + cfg["django"] = serde_json::json!({ "appLabel": "storefront" }); + std_fs::write( + "vespertide.json", + serde_json::to_string_pretty(&cfg).unwrap(), + ) + .unwrap(); + write_model(Path::new("models/gadgets.json"), &sample_table("gadgets")); + + cmd_export(Orm::Django, None).await.unwrap(); + + let written = std_fs::read_to_string(PathBuf::from("src/models/models.py")).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 26d73d7a..fb7fa9bd 100644 --- a/crates/vespertide-cli/src/commands/export/tests/mod.rs +++ b/crates/vespertide-cli/src/commands/export/tests/mod.rs @@ -6,6 +6,7 @@ pub(super) use std::fs as std_fs; pub(super) use tempfile::tempdir; pub(super) use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType, TableConstraint}; +mod django; mod drizzle; mod gorm; mod models_file; diff --git a/crates/vespertide-cli/src/commands/export/tests/models_file.rs b/crates/vespertide-cli/src/commands/export/tests/models_file.rs index f587189d..caabf618 100644 --- a/crates/vespertide-cli/src/commands/export/tests/models_file.rs +++ b/crates/vespertide-cli/src/commands/export/tests/models_file.rs @@ -29,11 +29,12 @@ fn write_models_across_directories() { 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. +/// Model directories do not reach the output. A Go directory is one package +/// and Django loads one `models` module, so both tables land in the same file +/// and the relation between them resolves without an import. #[rstest] #[case::gorm(Orm::Gorm, "models.go")] +#[case::django(Orm::Django, "models.py")] #[serial] #[tokio::test] async fn export_writes_nested_model_directories_into_one_file( @@ -55,11 +56,12 @@ async fn export_writes_nested_model_directories_into_one_file( }); } -/// 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 +/// The export root doubles as a source directory — a Go package, a Django app +/// — 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")] +#[case::django(Orm::Django, "models.py", "admin.py", "migrations/0001_initial.py")] #[serial] #[tokio::test] async fn export_replaces_only_its_own_models_file( @@ -94,10 +96,16 @@ async fn export_replaces_only_its_own_models_file( ); } -/// `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. +/// `models.go` and `models.py` are names the user may already own — `startapp` +/// writes the latter — 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")] +#[case::django( + Orm::Django, + "models.py", + "from django.db import models\n\n# Create your models here.\n" +)] #[serial] #[tokio::test] async fn export_refuses_a_models_file_it_did_not_write( diff --git a/crates/vespertide-cli/src/commands/export/tests/snapshots/vespertide_cli__commands__export__tests__django__export_django_writes_the_configured_app_label_into_meta.snap b/crates/vespertide-cli/src/commands/export/tests/snapshots/vespertide_cli__commands__export__tests__django__export_django_writes_the_configured_app_label_into_meta.snap new file mode 100644 index 00000000..d7ac673a --- /dev/null +++ b/crates/vespertide-cli/src/commands/export/tests/snapshots/vespertide_cli__commands__export__tests__django__export_django_writes_the_configured_app_label_into_meta.snap @@ -0,0 +1,18 @@ +--- +source: crates/vespertide-cli/src/commands/export/tests/django.rs +expression: written +--- +# Code generated by vespertide. DO NOT EDIT. + +from __future__ import annotations + +from django.db import models + + +class Gadgets(models.Model): + id = models.IntegerField(primary_key=True) + + class Meta: + managed = False + db_table = "gadgets" + app_label = "storefront" 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@Django.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@Django.snap new file mode 100644 index 00000000..1f8c08f1 --- /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@Django.snap @@ -0,0 +1,25 @@ +--- +source: crates/vespertide-cli/src/commands/export/tests/models_file.rs +expression: written +--- +# Code generated by vespertide. DO NOT EDIT. + +from __future__ import annotations + +from django.db import models + + +class User(models.Model): + id = models.IntegerField(primary_key=True) + + class Meta: + managed = False + db_table = "user" + +class Post(models.Model): + id = models.IntegerField(primary_key=True) + user = models.ForeignKey("User", on_delete=models.RESTRICT, related_name="+") + + class Meta: + managed = False + db_table = "post" From 52e3b668283c71b68050aad7ff6fe3b8334a465f Mon Sep 17 00:00:00 2001 From: JaeHyunAn <98042706+yyuneu@users.noreply.github.com> Date: Sun, 20 Sep 2026 15:40:24 +0900 Subject: [PATCH 12/12] =?UTF-8?q?docs:=20Django=20=EB=B0=B1=EC=97=94?= =?UTF-8?q?=EB=93=9C=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_XRCvp4xmrYU0QgPi9kMB.json | 1 + AGENTS.md | 18 ++-- README.md | 5 +- bridge/node/README.md | 2 +- crates/vespertide-cli/AGENTS.md | 8 +- crates/vespertide-exporter/AGENTS.md | 92 +++++++++++++++++-- crates/vespertide/src/lib.rs | 2 +- 7 files changed, 101 insertions(+), 27 deletions(-) create mode 100644 .changepacks/changepack_log_XRCvp4xmrYU0QgPi9kMB.json diff --git a/.changepacks/changepack_log_XRCvp4xmrYU0QgPi9kMB.json b/.changepacks/changepack_log_XRCvp4xmrYU0QgPi9kMB.json new file mode 100644 index 00000000..f25786db --- /dev/null +++ b/.changepacks/changepack_log_XRCvp4xmrYU0QgPi9kMB.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": "Django(Python) 익스포터를 8번째 ORM 백엔드로 추가. `Orm`이 exhaustive pub enum이라 `Orm::Django` 추가가 0.x 기준 breaking이고, vespertide-config에 `django` 설정 섹션(`appLabel`)이, vespertide-cli에 `export --orm django` 경로가 함께 들어간다(스키마 전체를 `models.py` 한 파일로 쓰고, 모델은 `managed = False`로 나간다). 기존 파이썬 백엔드(SQLAlchemy·SQLModel)도 파이썬 키워드 컬럼명을 이스케이프한다. published 크레이트를 전부 같은 Minor로 올리는 이유는 #185·#186과 동일하다: [workspace.dependencies]의 `=` 핀으로 물려 있어 일부만 올리면 핀과 크레이트 버전이 어긋나 resolve가 깨진다.", "date": "2026-09-20T09:00:00.0000000Z"} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 3b883cc6..6a2ef87e 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, GORM +│ ├── vespertide-exporter/ # ORM codegen: SeaORM, SQLAlchemy, SQLModel, JPA, Prisma, Drizzle, GORM, Django │ ├── 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,gorm}/` | Backend-specific generators | +| ORM export | `vespertide-exporter/src/{seaorm,sqlalchemy,sqlmodel,jpa,prisma,drizzle,gorm,django}/` | 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 | @@ -103,7 +103,7 @@ When constructing struct literals (e.g. `TableDef { name: ... }`), prefer `.into from string literals over the explicit constructor for terseness. ### `#[non_exhaustive]` Structs (0.2.0+) -`VespertideConfig`, `SeaOrmConfig`, `MigrationOptions` are `#[non_exhaustive]`: +`VespertideConfig`, `SeaOrmConfig`, `DjangoConfig`, `MigrationOptions` are `#[non_exhaustive]`: external callers MUST construct via `..Default::default()` or the provided constructor. @@ -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 7-ORM `orm_cases!` macro; snapshots must cross-compare all ORMs | +| Per-ORM exporter snapshot test (single ORM) | Use the 8-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` | 1168 | test-file (≤1200) | Shared 7-ORM fixture schemas | +| `exporter/src/tests/fixtures/mod.rs` | 1173 | test-file (≤1200) | Shared 8-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 seven-ORM `orm_cases!` — fan out +`{PG, MySQL, SQLite}` triple and the exporter's eight-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` (7 ORMs via `Orm` enum, 525 cross-ORM snapshots). When +and `vespertide-exporter` (8 ORMs via `Orm` enum, 632 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 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. +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 eight ORMs** (`Orm::SeaOrm`, `Orm::SqlAlchemy`, `Orm::SqlModel`, `Orm::Jpa`, `Orm::Prisma`, `Orm::Drizzle`, `Orm::Gorm`, `Orm::Django`). A new export scenario = ONE fixture + ONE `orm_cases!(...)` line, producing exactly eight 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/`, `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. +FORBIDDEN: per-ORM `#[test]` snapshot functions inside `src/seaorm/`, `src/sqlalchemy/`, `src/sqlmodel/`, `src/jpa/`, `src/prisma/`, `src/drizzle/`, `src/gorm/`, `src/django/`, 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 eight. 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 d91d7e2a..9b91a077 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, GORM +- **ORM Export**: Export schemas to SeaORM, SQLAlchemy, SQLModel, JPA, Prisma, Drizzle, GORM, Django - **Language Server**: First-class editor support via the bundled `vespertide-lsp` — see [LSP Features](#lsp-features) below ## What's new in 0.2.0 @@ -24,7 +24,7 @@ Declarative database schema management. Define your schemas in JSON, and Vespert API stability pass with a byte-identical JSON wire format — existing models and migration files load unchanged. - **Newtype identifiers**: `TableName`, `ColumnName`, `IndexName` in `vespertide-core` (`crates/vespertide-core/src/schema/names.rs`). `#[serde(transparent)]` keeps JSON identical; `Deref` means most call sites need no edit. -- **`#[non_exhaustive]` configs**: `VespertideConfig`, `SeaOrmConfig`, and `MigrationOptions` must be built with `..Default::default()` (or `MigrationOptions::new()`), so future fields don't break semver. +- **`#[non_exhaustive]` configs**: `VespertideConfig`, `SeaOrmConfig`, `DjangoConfig`, and `MigrationOptions` must be built with `..Default::default()` (or `MigrationOptions::new()`), so future fields don't break semver. - **Decomposed `QueryError`**: new `InvalidColumnType`, `SchemaError`, `BackendError`, and `UnsupportedAction` variants. `QueryError::Other(String)` is `#[deprecated]` but still compiles. - **Cloneable `MigrationError`**: backed by `Arc`, so retry loops can re-emit errors without re-running the planner. - **Faster LSP**: every editor hot path (diagnostics, symbols, drift) is now `RingCache`-backed in `vespertide-lsp`. No API change; -99% latency on the synthetic `tools/lsp-profile/` workload. @@ -246,6 +246,7 @@ 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) +vespertide export --orm django # Python - Django models (models.py) ``` ## Runtime Migrations (Macro) diff --git a/bridge/node/README.md b/bridge/node/README.md index c6989ba8..2080745b 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, GORM). +and SQLite plus ORM code (SeaORM, SQLAlchemy, SQLModel, JPA, Prisma, Drizzle, GORM, Django). 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 0ac5e797..a8ea03d4 100644 --- a/crates/vespertide-cli/AGENTS.md +++ b/crates/vespertide-cli/AGENTS.md @@ -21,8 +21,8 @@ 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/GORM) — - │ # mod.rs + tests/ (mod.rs, prisma.rs, drizzle.rs, gorm.rs, + ├── export/ # Export to ORM code (SeaORM/SQLAlchemy/SQLModel/JPA/Prisma/Drizzle/GORM/Django) — + │ # mod.rs + tests/ (mod.rs, prisma.rs, drizzle.rs, gorm.rs, django.rs, │ # models_file.rs) └── erd/ # ERD diagram export — mod.rs, mermaid.rs, dot.rs, svg/ (style, model, # layout, edges, render, util), tests/ @@ -39,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)` | per-table `render_entity_with_schema()` + mod.rs wiring; Prisma/Drizzle/GORM render the whole schema into fixed file names | +| `export --orm` | `cmd_export(orm, dir)` | per-table `render_entity_with_schema()` + mod.rs wiring; Prisma/Drizzle/GORM/Django 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 @@ -56,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, 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 +- **export/**: Generates the `mod.rs` chain for SeaORM exports; Python/Java ORMs skip it. Prisma, Drizzle, GORM and Django 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` and Django `models.py`. The last two skip 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 f6abe5fa..be58ca0d 100644 --- a/crates/vespertide-exporter/AGENTS.md +++ b/crates/vespertide-exporter/AGENTS.md @@ -1,21 +1,21 @@ # vespertide-exporter -ORM code generation from `TableDef` schemas → SeaORM (Rust), SQLAlchemy (Python), SQLModel (Python), JPA (Java), Prisma (schema.prisma), Drizzle (TypeScript), GORM (Go). +ORM code generation from `TableDef` schemas → SeaORM (Rust), SQLAlchemy (Python), SQLModel (Python), JPA (Java), Prisma (schema.prisma), Drizzle (TypeScript), GORM (Go), Django (Python). ## STRUCTURE ``` src/ ├── lib.rs # Re-exports all backends -├── orm.rs # OrmExporter trait, Orm enum (SeaOrm/SqlAlchemy/SqlModel/Jpa/Prisma/Drizzle/Gorm), +├── orm.rs # OrmExporter trait, Orm enum (SeaOrm/SqlAlchemy/SqlModel/Jpa/Prisma/Drizzle/Gorm/Django), │ # Orm::file_extension(), dispatch ├── constraint_scan.rs # Shared constraint scans + FK relation naming │ # (single_column_fk_details/junction_targets/fk_relation_names/relation_segment/ │ # collect_back_relations) -├── enum_scan.rs # Shared enum-column scans (Prisma/Drizzle/GORM) +├── enum_scan.rs # Shared enum-column scans (Prisma/Drizzle/GORM/Django) ├── parallel_config.rs # Rayon parallelism thresholds -├── python_naming.rs # Shared PascalCase naming (SQLAlchemy/SQLModel/JPA/GORM/CLI) -├── scope_names.rs # Top-level names claimed once per schema (GORM package) +├── python_naming.rs # Shared PascalCase naming (SQLAlchemy/SQLModel/JPA/Django/GORM/CLI) +├── scope_names.rs # Top-level names claimed once per schema (GORM package / Django module) ├── 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 @@ -24,20 +24,29 @@ src/ ├── prisma/ # mod.rs, render.rs, types.rs, enums.rs — schema.prisma models ├── drizzle/ # mod.rs, render.rs, types.rs, enums.rs — Drizzle TypeScript models ├── gorm/ # mod.rs, render.rs, types.rs, enums.rs — GORM structs +├── django/ # mod.rs, render.rs, types.rs, enums.rs — Django models.Model classes ├── 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), +│ # python.rs (render_enum/enum_member_name/unmangled/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, and GORM, -which also upper-cases the first letter because Go exports by case), plus +with `IdentifierStart::Underscore` (Java, SQLAlchemy, Django fields and choices +classes, ERD) or `IdentifierStart::Letter` (SeaORM, SQLModel/Pydantic, Prisma, +Drizzle, Django model classes, 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). +Python keywords are escaped by `utils/python.rs::escape_python_keyword` (PEP 8's trailing +`_`) in SQLAlchemy and SQLModel. Django cannot use that form — fields.E001 forbids a +trailing `_` — so `django/render.rs::django_field_name` applies Django's field checks +(no `__`, no trailing `_`, not a keyword, not one of the model's own attributes in +`MODEL_ATTRIBUTES` — `pk`, `save`, `check`, `objects`, `Meta`, …) with `inspectdb`'s `_field` +repairs. + ## WHERE TO LOOK | Task | Location | @@ -130,6 +139,69 @@ SQLAlchemy's positional column name). mapping; `render.rs` field and relation naming, package-scope constants, struct-tag escaping, default tags; `mod.rs` package-name inference) +### Django (Python) +- **Module scope**: model classes and choices classes share one module, so they are claimed + through the same `scope_names::ScopeNames` (models first; a choices class is bare while + nothing else holds the identifier, otherwise `{Model}{Enum}`). Members are scoped to their + class and numbered there when two values fold onto one name — Python's `Enum` refuses a + repeated member at import time. A choices class or member led by `__` keeps a single `_` + (`utils/python.rs::unmangled`): Python mangles such a name inside a class body, so the member + would be no member and the model could not name the class +- Renders `models.Model` classes with a `class Meta` (`managed = False` — vespertide owns the DDL, + so `makemigrations` must not create or alter these tables — `db_table`, `indexes`, `constraints`). + `UniqueConstraint` names come from `build_unique_constraint_name` with the source name as the + key, matching the SQL layer; `Meta.indexes` use `build_index_name` the same way while the + result fits Django's 30-character cap on index names (models.E034), and carry no `name=` + past it — Django never creates the index of an unmanaged model, so its own name will do. + The built name is `ix_{table}__{key}`, so a long table name reaches the cap on its own and + even a short source name then goes unnamed +- **JSONB**: a `Custom` column type spelled `jsonb` maps to `models.JSONField` (the shared + `is_jsonb_custom_type`); other custom types fall back to `TextField` +- **M2M junction detection**: `constraint_scan::junction_targets` (shared with SeaORM) recognizes + composite-PK, 2+ FK junction tables; each side gets `ManyToManyField(..., through=..., + related_name="+")`, named after the pluralized target (`{target}_via_{junction}` when two + junctions reach one target) and run through `django_field_name` after the columns, so it never + shadows a scalar field. Purely self-referential junctions are skipped rather than guessed at, + and so is a junction that reaches either end by a composite key: that key renders as a + comment, a `through` model needs a real `ForeignKey` to both ends (fields.E336), and Django + cannot relate to the composite-key model it points at (fields.E347) +- **Names and actions Django's checks reject**: a model class never starts with `_` (models.E023; + `1users` → `x1users`, the same letter escape SQLModel uses), and `on_delete=SET_DEFAULT` is only + emitted when the FK column has a default, which then renders as `default=`; without one it + falls back to `DO_NOTHING` (fields.E321), and so does `SET_NULL` on a field that is not null + (fields.E320) — the table is unmanaged, so the database keeps applying its own rule + (`types.rs::on_delete_for`) +- **Composite (multi-column) FK**: Django has no native multi-column FK field, so + `collect_composite_fks` (from `utils/common.rs`, shared with SQLAlchemy, SQLModel and GORM) + emits a `# composite foreign key: (...) -> ref_table(...)` comment instead of silently dropping + the relationship +- **`build_default()`**: only emits a bare (unquoted) SQL default when it parses as a numeric + literal — an unrecognized bare constant (e.g. a named SQL constant) is omitted rather than + emitted as an undefined Python name. A quoted default becomes the Python string it spells + (`'it''s'` → `"it's"`), and a `JSONField` gets none: Django wants a callable there + (fields.E010), and the SQL literal is the document's text rather than its value +- **PK kwarg**: `primary_key=True` is always emitted for the (non-composite) PK column, regardless + of field type — `models.AutoField`/`SmallAutoField`/`BigAutoField` do **not** imply + `primary_key=True` in real Django; omitting it fails Django's own `fields.E100` system check +- **FK fields**: a FK column that is the table's (non-composite) PK or carries a single-column + unique renders as `models.OneToOneField` — `ForeignKey(unique=True)` is only fields.W342 pointing + at that class — and the PK one keeps `primary_key=True`. A key that references anything but + the target's primary key carries `to_field=` (Django would otherwise join on the primary key + and silently return the wrong rows), named through the target's own `column_field_names`. A + key into a model with a composite primary key, or onto a column that is neither the target's + primary key nor unique on its own, stays a plain column plus a + `# foreign key: (col) -> table(ref)` comment: Django cannot relate to such a model + (fields.E347) or through such a field (fields.E311). A key claims its attname along with its + field name (`claim_relation_field_name`): Django stores it under `{field}_id`, which a plain + column of that name would clash with (models.E006) +- **Config**: `DjangoExporterWithConfig` for `app_label` (omitted from `Meta` when unset); its + `export` renders the whole schema as one module, which is what the CLI writes (`models.py`) — + Django loads an app's models from its one `models` module +- **Tests**: rendered output is pinned by the shared `orm_cases!` suite; the inline + `#[cfg(test)] mod tests` blocks hold only function-level unit tests (`mod.rs` field-class, + `on_delete` and string-default mappings; `render.rs` field-name repairs, attname claims, + relatable keys, choices-class names; `enums.rs` member numbering) + ### 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 @@ -168,7 +240,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 -- 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 +- 646 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 eight) — 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 c157dae9..fae55438 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/Prisma/Drizzle/GORM. +//! ORM models for SeaORM/SQLAlchemy/SQLModel/JPA/Prisma/Drizzle/GORM/Django. //! //! This is the facade crate; runtime migrations use [`vespertide_migration!`]. //! Advanced users may depend on `vespertide-core` directly for typed data structures.