From 12825c82703efcf35bfb468ac0384c624f2a4435 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 4 Feb 2026 00:38:17 +0900 Subject: [PATCH 1/5] Impl sea schema-type --- Cargo.lock | 6 +- crates/vespera_macro/src/lib.rs | 42 +- crates/vespera_macro/src/parser/schema.rs | 118 +- crates/vespera_macro/src/schema_macro.rs | 1269 ++++++++++++++++- .../axum-example/models/memo.vespertide.json | 18 + .../axum-example/models/user.vespertide.json | 11 + examples/axum-example/openapi.json | 168 ++- examples/axum-example/src/lib.rs | 10 +- examples/axum-example/src/main.rs | 2 +- examples/axum-example/src/models/memo.rs | 18 +- examples/axum-example/src/models/mod.rs | 1 + examples/axum-example/src/models/user.rs | 27 + examples/axum-example/src/routes/memos.rs | 37 +- .../axum-example/tests/integration_test.rs | 38 +- .../snapshots/integration_test__openapi.snap | 146 +- examples/axum-example/vespertide.json | 18 + openapi.json | 168 ++- 17 files changed, 1988 insertions(+), 109 deletions(-) create mode 100644 examples/axum-example/models/memo.vespertide.json create mode 100644 examples/axum-example/models/user.vespertide.json create mode 100644 examples/axum-example/src/models/user.rs create mode 100644 examples/axum-example/vespertide.json diff --git a/Cargo.lock b/Cargo.lock index 22af57c..083b597 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3073,7 +3073,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vespera" -version = "0.1.26" +version = "0.1.27" dependencies = [ "axum", "axum-extra", @@ -3086,7 +3086,7 @@ dependencies = [ [[package]] name = "vespera_core" -version = "0.1.26" +version = "0.1.27" dependencies = [ "rstest", "serde", @@ -3095,7 +3095,7 @@ dependencies = [ [[package]] name = "vespera_macro" -version = "0.1.26" +version = "0.1.27" dependencies = [ "anyhow", "insta", diff --git a/crates/vespera_macro/src/lib.rs b/crates/vespera_macro/src/lib.rs index 9733a90..ee4f171 100644 --- a/crates/vespera_macro/src/lib.rs +++ b/crates/vespera_macro/src/lib.rs @@ -66,12 +66,37 @@ pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream { static SCHEMA_STORAGE: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); +/// Extract custom schema name from #[schema(name = "...")] attribute +fn extract_schema_name_attr(attrs: &[syn::Attribute]) -> Option { + for attr in attrs { + if attr.path().is_ident("schema") { + let mut custom_name = None; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("name") { + let value = meta.value()?; + let lit: syn::LitStr = value.parse()?; + custom_name = Some(lit.value()); + } + Ok(()) + }); + if custom_name.is_some() { + return custom_name; + } + } + } + None +} + /// Process derive input and return metadata + expanded code fn process_derive_schema(input: &syn::DeriveInput) -> (StructMetadata, proc_macro2::TokenStream) { let name = &input.ident; let generics = &input.generics; + + // Check for custom schema name from #[schema(name = "...")] attribute + let schema_name = extract_schema_name_attr(&input.attrs).unwrap_or_else(|| name.to_string()); + // Schema-derived types appear in OpenAPI spec (include_in_openapi: true) - let metadata = StructMetadata::new(name.to_string(), quote::quote!(#input).to_string()); + let metadata = StructMetadata::new(schema_name, quote::quote!(#input).to_string()); let (impl_generics, ty_generics, where_clause) = generics.split_for_impl(); let expanded = quote! { impl #impl_generics vespera::schema::SchemaBuilder for #name #ty_generics #where_clause {} @@ -80,7 +105,9 @@ fn process_derive_schema(input: &syn::DeriveInput) -> (StructMetadata, proc_macr } /// Derive macro for Schema -#[proc_macro_derive(Schema)] +/// +/// Supports `#[schema(name = "CustomName")]` attribute to set custom OpenAPI schema name. +#[proc_macro_derive(Schema, attributes(schema))] pub fn derive_schema(input: TokenStream) -> TokenStream { let input = syn::parse_macro_input!(input as syn::DeriveInput); let (metadata, expanded) = process_derive_schema(&input); @@ -205,10 +232,17 @@ pub fn schema_type(input: TokenStream) -> TokenStream { let input = syn::parse_macro_input!(input as schema_macro::SchemaTypeInput); // Get stored schemas - let storage = SCHEMA_STORAGE.lock().unwrap(); + let mut storage = SCHEMA_STORAGE.lock().unwrap(); match schema_macro::generate_schema_type_code(&input, &storage) { - Ok(tokens) => TokenStream::from(tokens), + Ok((tokens, generated_metadata)) => { + // If custom name is provided, register the schema directly + // This ensures it appears in OpenAPI even when `ignore` is set + if let Some(metadata) = generated_metadata { + storage.push(metadata); + } + TokenStream::from(tokens) + } Err(e) => e.to_compile_error().into(), } } diff --git a/crates/vespera_macro/src/parser/schema.rs b/crates/vespera_macro/src/parser/schema.rs index 475000c..15cd023 100644 --- a/crates/vespera_macro/src/parser/schema.rs +++ b/crates/vespera_macro/src/parser/schema.rs @@ -9,6 +9,49 @@ pub fn strip_raw_prefix(ident: &str) -> &str { ident.strip_prefix("r#").unwrap_or(ident) } +/// Extract a Schema name from a SeaORM Entity type path. +/// +/// Converts paths like: +/// - `super::user::Entity` → "User" +/// - `crate::models::memo::Entity` → "Memo" +/// +/// The schema name is derived from the module containing Entity, +/// converted to PascalCase (first letter uppercase). +fn extract_schema_name_from_entity(ty: &Type) -> Option { + match ty { + Type::Path(type_path) => { + let segments: Vec<_> = type_path.path.segments.iter().collect(); + + // Need at least 2 segments: module::Entity + if segments.len() < 2 { + return None; + } + + // Check if last segment is "Entity" + let last = segments.last()?; + if last.ident != "Entity" { + return None; + } + + // Get the second-to-last segment (module name) + let module_segment = segments.get(segments.len() - 2)?; + let module_name = module_segment.ident.to_string(); + + // Convert to PascalCase (capitalize first letter) + let schema_name = { + let mut chars = module_name.chars(); + match chars.next() { + None => module_name, + Some(first) => first.to_uppercase().chain(chars).collect(), + } + }; + + Some(schema_name) + } + _ => None, + } +} + pub fn extract_rename_all(attrs: &[syn::Attribute]) -> Option { for attr in attrs { if attr.path().is_ident("serde") { @@ -870,6 +913,12 @@ pub(super) fn parse_type_to_schema_ref_with_schemas( // Handle generic types if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { match ident_str.as_str() { + // Box -> T's schema (Box is just heap allocation, transparent for schema) + "Box" => { + if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() { + return parse_type_to_schema_ref(inner_ty, known_schemas, struct_definitions); + } + } "Vec" | "Option" => { if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() { let inner_schema = parse_type_to_schema_ref( @@ -899,6 +948,36 @@ pub(super) fn parse_type_to_schema_ref_with_schemas( } } } + // SeaORM relation types: convert Entity to Schema reference + "HasOne" => { + // HasOne -> nullable reference to corresponding Schema + if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() { + if let Some(schema_name) = extract_schema_name_from_entity(inner_ty) { + return SchemaRef::Inline(Box::new(Schema { + ref_path: Some(format!("#/components/schemas/{}", schema_name)), + schema_type: None, + nullable: Some(true), + ..Schema::new(SchemaType::Object) + })); + } + } + // Fallback: generic object + return SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object))); + } + "HasMany" => { + // HasMany -> array of references to corresponding Schema + if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() { + if let Some(schema_name) = extract_schema_name_from_entity(inner_ty) { + let inner_ref = + SchemaRef::Ref(Reference::new(format!("#/components/schemas/{}", schema_name))); + return SchemaRef::Inline(Box::new(Schema::array(inner_ref))); + } + } + // Fallback: array of generic objects + return SchemaRef::Inline(Box::new(Schema::array(SchemaRef::Inline( + Box::new(Schema::new(SchemaType::Object)), + )))); + } "HashMap" | "BTreeMap" => { // HashMap or BTreeMap -> object with additionalProperties // K is typically String, we use V as the value type @@ -987,12 +1066,45 @@ pub(super) fn parse_type_to_schema_ref_with_schemas( // Use just the type name (handles both crate::TestStruct and TestStruct) let type_name = ident_str.clone(); - if known_schemas.contains_key(&type_name) { + // For paths like `module::Schema`, try to find the schema name + // by checking if there's a schema named `ModuleSchema` or `ModuleNameSchema` + let resolved_name = if type_name == "Schema" && path.segments.len() > 1 { + // Get the parent module name (e.g., "user" from "crate::models::user::Schema") + let parent_segment = &path.segments[path.segments.len() - 2]; + let parent_name = parent_segment.ident.to_string(); + + // Try PascalCase version: "user" -> "UserSchema" + let pascal_name = { + let mut chars = parent_name.chars(); + match chars.next() { + None => String::new(), + Some(c) => { + c.to_uppercase().collect::() + chars.as_str() + "Schema" + } + } + }; + + if known_schemas.contains_key(&pascal_name) { + pascal_name + } else { + // Try lowercase version: "userSchema" + let lower_name = format!("{}Schema", parent_name); + if known_schemas.contains_key(&lower_name) { + lower_name + } else { + type_name.clone() + } + } + } else { + type_name.clone() + }; + + if known_schemas.contains_key(&resolved_name) { // Check if this is a generic type with type parameters if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { // This is a concrete generic type like GenericStruct // Inline the schema by substituting generic parameters with concrete types - if let Some(base_def) = struct_definitions.get(&type_name) + if let Some(base_def) = struct_definitions.get(&resolved_name) && let Ok(mut parsed) = syn::parse_str::(base_def) { // Extract generic parameter names from the struct definition @@ -1049,7 +1161,7 @@ pub(super) fn parse_type_to_schema_ref_with_schemas( } } // Non-generic type or generic without parameters - use reference - SchemaRef::Ref(Reference::schema(&type_name)) + SchemaRef::Ref(Reference::schema(&resolved_name)) } else { // For unknown custom types, return object schema instead of reference // This prevents creating invalid references to non-existent schemas diff --git a/crates/vespera_macro/src/schema_macro.rs b/crates/vespera_macro/src/schema_macro.rs index dc5381f..44e0d11 100644 --- a/crates/vespera_macro/src/schema_macro.rs +++ b/crates/vespera_macro/src/schema_macro.rs @@ -9,7 +9,7 @@ use quote::quote; use std::collections::HashSet; use std::path::Path; use syn::punctuated::Punctuated; -use syn::{Ident, LitStr, Token, Type, bracketed, parenthesized, parse::Parse, parse::ParseStream}; +use syn::{bracketed, parenthesized, parse::Parse, parse::ParseStream, Ident, LitStr, Token, Type}; use crate::metadata::StructMetadata; use crate::parser::{ @@ -156,6 +156,398 @@ fn extract_type_name(ty: &Type) -> Result { } } +/// Check if a type is a qualified path (has multiple segments like crate::models::User) +fn is_qualified_path(ty: &Type) -> bool { + match ty { + Type::Path(type_path) => type_path.path.segments.len() > 1, + _ => false, + } +} + +/// Check if a type is a SeaORM relation type (HasOne, HasMany, BelongsTo) +fn is_seaorm_relation_type(ty: &Type) -> bool { + match ty { + Type::Path(type_path) => { + if let Some(segment) = type_path.path.segments.last() { + let ident = segment.ident.to_string(); + matches!(ident.as_str(), "HasOne" | "HasMany" | "BelongsTo") + } else { + false + } + } + _ => false, + } +} + +/// Check if a struct is a SeaORM Model (has #[sea_orm::model] or #[sea_orm(table_name = ...)] attribute) +fn is_seaorm_model(struct_item: &syn::ItemStruct) -> bool { + for attr in &struct_item.attrs { + // Check for #[sea_orm::model] or #[sea_orm(...)] + let path = attr.path(); + if path.is_ident("sea_orm") { + return true; + } + // Check for path like sea_orm::model + let segments: Vec<_> = path.segments.iter().map(|s| s.ident.to_string()).collect(); + if segments.first().is_some_and(|s| s == "sea_orm") { + return true; + } + } + false +} + +/// Relation field info for generating from_model code +#[derive(Clone)] +struct RelationFieldInfo { + /// Field name in the generated struct + field_name: syn::Ident, + /// Relation type: "HasOne", "HasMany", or "BelongsTo" + relation_type: String, + /// Target Schema path (e.g., crate::models::user::Schema) + schema_path: TokenStream, + /// Whether the relation is optional + is_optional: bool, + /// Foreign key field name (for BelongsTo) + fk_field: Option, +} + +/// Extract the "from" field name from a sea_orm belongs_to attribute. +/// e.g., `#[sea_orm(belongs_to, from = "user_id", to = "id")]` → Some("user_id") +fn extract_belongs_to_from_field(attrs: &[syn::Attribute]) -> Option { + for attr in attrs { + if attr.path().is_ident("sea_orm") { + let mut from_field = None; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("from") { + if let Ok(value) = meta.value() { + if let Ok(lit) = value.parse::() { + from_field = Some(lit.value()); + } + } + } + Ok(()) + }); + if from_field.is_some() { + return from_field; + } + } + } + None +} + +/// Check if a field in the struct is optional (Option). +fn is_field_optional_in_struct(struct_item: &syn::ItemStruct, field_name: &str) -> bool { + if let syn::Fields::Named(fields_named) = &struct_item.fields { + for field in &fields_named.named { + if let Some(ident) = &field.ident { + if ident == field_name { + return is_option_type(&field.ty); + } + } + } + } + false +} + +/// Convert a SeaORM relation type to a Schema type AND return relation info. +/// +/// - `#[sea_orm(has_one)]` → Always `Option>` +/// - `#[sea_orm(has_many)]` → Always `Vec` +/// - `#[sea_orm(belongs_to, from = "field")]`: +/// - If `from` field is `Option` → `Option>` +/// - If `from` field is required → `Box` +/// +/// The `source_module_path` is used to resolve relative paths like `super::`. +/// e.g., if source is `crate::models::memo::Model`, module path is `crate::models::memo` +/// +/// Returns None if the type is not a relation type or conversion fails. +/// Returns (TokenStream, RelationFieldInfo) on success for use in from_model generation. +fn convert_relation_type_to_schema_with_info( + ty: &Type, + field_attrs: &[syn::Attribute], + parsed_struct: &syn::ItemStruct, + source_module_path: &[String], + field_name: syn::Ident, +) -> Option<(TokenStream, RelationFieldInfo)> { + let type_path = match ty { + Type::Path(tp) => tp, + _ => return None, + }; + + let segment = type_path.path.segments.last()?; + let ident_str = segment.ident.to_string(); + + // Check if this is a relation type with generic argument + let args = match &segment.arguments { + syn::PathArguments::AngleBracketed(args) => args, + _ => return None, + }; + + // Get the inner Entity type + let inner_ty = match args.args.first()? { + syn::GenericArgument::Type(ty) => ty, + _ => return None, + }; + + // Extract the path and convert to absolute Schema path + let inner_path = match inner_ty { + Type::Path(tp) => tp, + _ => return None, + }; + + // Collect segments as strings + let segments: Vec = inner_path + .path + .segments + .iter() + .map(|s| s.ident.to_string()) + .collect(); + + // Convert path to absolute, resolving `super::` relative to source module + let absolute_segments: Vec = if !segments.is_empty() && segments[0] == "super" { + let super_count = segments.iter().take_while(|s| *s == "super").count(); + let parent_path_len = source_module_path.len().saturating_sub(super_count); + let mut abs = source_module_path[..parent_path_len].to_vec(); + for seg in segments.iter().skip(super_count) { + if seg == "Entity" { + abs.push("Schema".to_string()); + } else { + abs.push(seg.clone()); + } + } + abs + } else if !segments.is_empty() && segments[0] == "crate" { + segments + .iter() + .map(|s| { + if s == "Entity" { + "Schema".to_string() + } else { + s.clone() + } + }) + .collect() + } else { + let parent_path_len = source_module_path.len().saturating_sub(1); + let mut abs = source_module_path[..parent_path_len].to_vec(); + for seg in &segments { + if seg == "Entity" { + abs.push("Schema".to_string()); + } else { + abs.push(seg.clone()); + } + } + abs + }; + + // Build the absolute path as tokens + let path_idents: Vec = absolute_segments + .iter() + .map(|s| syn::Ident::new(s, proc_macro2::Span::call_site())) + .collect(); + let schema_path = quote! { #(#path_idents)::* }; + + // Convert based on relation type + match ident_str.as_str() { + "HasOne" => { + // HasOne → Check FK field to determine optionality + // If FK is Option → relation is optional: Option> + // If FK is required → relation is required: Box + let fk_field = extract_belongs_to_from_field(field_attrs); + let is_optional = fk_field + .as_ref() + .map(|f| is_field_optional_in_struct(parsed_struct, f)) + .unwrap_or(true); // Default to optional if we can't determine + + let converted = if is_optional { + quote! { Option> } + } else { + quote! { Box<#schema_path> } + }; + let info = RelationFieldInfo { + field_name, + relation_type: "HasOne".to_string(), + schema_path: schema_path.clone(), + is_optional, + fk_field, + }; + Some((converted, info)) + } + "HasMany" => { + let converted = quote! { Vec<#schema_path> }; + let info = RelationFieldInfo { + field_name, + relation_type: "HasMany".to_string(), + schema_path: schema_path.clone(), + is_optional: false, + fk_field: None, + }; + Some((converted, info)) + } + "BelongsTo" => { + // BelongsTo → Check FK field to determine optionality + // If FK is Option → relation is optional: Option> + // If FK is required → relation is required: Box + let fk_field = extract_belongs_to_from_field(field_attrs); + let is_optional = fk_field + .as_ref() + .map(|f| is_field_optional_in_struct(parsed_struct, f)) + .unwrap_or(true); // Default to optional if we can't determine + + let converted = if is_optional { + quote! { Option> } + } else { + quote! { Box<#schema_path> } + }; + let info = RelationFieldInfo { + field_name, + relation_type: "BelongsTo".to_string(), + schema_path: schema_path.clone(), + is_optional, + fk_field, + }; + Some((converted, info)) + } + _ => None, + } +} + +/// Convert a SeaORM relation type to a Schema type. +/// +/// - `#[sea_orm(has_one)]` → Always `Option>` +/// - `#[sea_orm(has_many)]` → Always `Vec` +/// - `#[sea_orm(belongs_to, from = "field")]`: +/// - If `from` field is `Option` → `Option>` +/// - If `from` field is required → `Box` +/// +/// The `source_module_path` is used to resolve relative paths like `super::`. +/// e.g., if source is `crate::models::memo::Model`, module path is `crate::models::memo` +/// +/// Returns None if the type is not a relation type or conversion fails. +#[allow(dead_code)] +fn convert_relation_type_to_schema( + ty: &Type, + field_attrs: &[syn::Attribute], + parsed_struct: &syn::ItemStruct, + source_module_path: &[String], +) -> Option { + let type_path = match ty { + Type::Path(tp) => tp, + _ => return None, + }; + + let segment = type_path.path.segments.last()?; + let ident_str = segment.ident.to_string(); + + // Check if this is a relation type with generic argument + let args = match &segment.arguments { + syn::PathArguments::AngleBracketed(args) => args, + _ => return None, + }; + + // Get the inner Entity type + let inner_ty = match args.args.first()? { + syn::GenericArgument::Type(ty) => ty, + _ => return None, + }; + + // Extract the path and convert to absolute Schema path + let inner_path = match inner_ty { + Type::Path(tp) => tp, + _ => return None, + }; + + // Collect segments as strings + let segments: Vec = inner_path + .path + .segments + .iter() + .map(|s| s.ident.to_string()) + .collect(); + + // Convert path to absolute, resolving `super::` relative to source module + // e.g., super::user::Entity with source_module_path = [crate, models, memo] + // → [crate, models, user, Schema] + let absolute_segments: Vec = if !segments.is_empty() && segments[0] == "super" { + // Count how many `super` segments + let super_count = segments.iter().take_while(|s| *s == "super").count(); + + // Go up `super_count` levels from source module path + let parent_path_len = source_module_path.len().saturating_sub(super_count); + let mut abs = source_module_path[..parent_path_len].to_vec(); + + // Append remaining segments (after super::), replacing Entity with Schema + for seg in segments.iter().skip(super_count) { + if seg == "Entity" { + abs.push("Schema".to_string()); + } else { + abs.push(seg.clone()); + } + } + abs + } else if !segments.is_empty() && segments[0] == "crate" { + // Already absolute path, just replace Entity with Schema + segments + .iter() + .map(|s| { + if s == "Entity" { + "Schema".to_string() + } else { + s.clone() + } + }) + .collect() + } else { + // Relative path without super, assume same module level + // Prepend source module's parent path + let parent_path_len = source_module_path.len().saturating_sub(1); + let mut abs = source_module_path[..parent_path_len].to_vec(); + for seg in &segments { + if seg == "Entity" { + abs.push("Schema".to_string()); + } else { + abs.push(seg.clone()); + } + } + abs + }; + + // Build the absolute path as tokens + let path_idents: Vec = absolute_segments + .iter() + .map(|s| syn::Ident::new(s, proc_macro2::Span::call_site())) + .collect(); + let schema_path = quote! { #(#path_idents)::* }; + + // Convert based on relation type + match ident_str.as_str() { + "HasOne" => { + // HasOne → Always Option> + Some(quote! { Option> }) + } + "HasMany" => { + // HasMany → Vec + Some(quote! { Vec<#schema_path> }) + } + "BelongsTo" => { + // BelongsTo → Check if "from" field is optional + if let Some(from_field) = extract_belongs_to_from_field(field_attrs) { + if is_field_optional_in_struct(parsed_struct, &from_field) { + // from field is Option → relation is optional + Some(quote! { Option> }) + } else { + // from field is required → relation is required + Some(quote! { Box<#schema_path> }) + } + } else { + // Fallback: treat as optional if we can't determine + Some(quote! { Option> }) + } + } + _ => None, + } +} + /// Generate Schema construction code with field filtering fn generate_filtered_schema( struct_item: &syn::ItemStruct, @@ -448,12 +840,76 @@ fn find_struct_from_path(ty: &Type) -> Option { None } +/// Find struct definition from a schema path string (e.g., "crate::models::user::Schema"). +/// +/// Similar to `find_struct_from_path` but takes a string path instead of syn::Type. +fn find_struct_from_schema_path(path_str: &str) -> Option { + // Get CARGO_MANIFEST_DIR to locate src folder + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok()?; + let src_dir = Path::new(&manifest_dir).join("src"); + + // Parse the path string into segments + let segments: Vec<&str> = path_str.split("::").filter(|s| !s.is_empty()).collect(); + + if segments.is_empty() { + return None; + } + + // The last segment is the struct name + let struct_name = segments.last()?.to_string(); + + // Build possible file paths from the module path + // e.g., crate::models::user::Schema -> src/models/user.rs + let module_segments: Vec<&str> = segments[..segments.len() - 1] + .iter() + .filter(|s| **s != "crate" && **s != "self" && **s != "super") + .copied() + .collect(); + + if module_segments.is_empty() { + return None; + } + + // Try different file path patterns + let file_paths = vec![ + src_dir.join(format!("{}.rs", module_segments.join("/"))), + src_dir.join(format!("{}/mod.rs", module_segments.join("/"))), + ]; + + for file_path in file_paths { + if !file_path.exists() { + continue; + } + + let content = std::fs::read_to_string(&file_path).ok()?; + let file_ast = syn::parse_file(&content).ok()?; + + // Look for the struct in the file + for item in &file_ast.items { + match item { + syn::Item::Struct(struct_item) if struct_item.ident == struct_name => { + return Some(StructMetadata::new_model( + struct_name.clone(), + quote::quote!(#struct_item).to_string(), + )); + } + _ => continue, + } + } + } + + None +} + /// Input for the schema_type! macro /// /// Syntax: `schema_type!(NewTypeName from SourceType, pick = ["field1", "field2"])` /// Or: `schema_type!(NewTypeName from SourceType, omit = ["field1", "field2"])` /// Or: `schema_type!(NewTypeName from SourceType, rename = [("old", "new")])` /// Or: `schema_type!(NewTypeName from SourceType, add = [("field": Type)])` +/// Or: `schema_type!(NewTypeName from SourceType, ignore)` - skip Schema derive +/// Or: `schema_type!(NewTypeName from SourceType, name = "CustomName")` - custom OpenAPI name +/// Or: `schema_type!(NewTypeName from SourceType, rename_all = "camelCase")` - serde rename_all pub struct SchemaTypeInput { /// The new type name to generate pub new_type: Ident, @@ -475,6 +931,15 @@ pub struct SchemaTypeInput { /// - `partial = ["field1", "field2"]` = only listed fields become `Option` /// - Fields already `Option` are left unchanged. pub partial: Option, + /// Whether to skip deriving the Schema trait (default: false) + /// Use `ignore` keyword to set this to true. + pub ignore_schema: bool, + /// Custom OpenAPI schema name (overrides Rust struct name) + /// Use `name = "CustomName"` to set this. + pub schema_name: Option, + /// Serde rename_all strategy (e.g., "camelCase", "snake_case", "PascalCase") + /// If not specified, defaults to "camelCase" when source has no rename_all + pub rename_all: Option, } /// Mode for the `partial` keyword in schema_type! @@ -549,6 +1014,9 @@ impl Parse for SchemaTypeInput { let mut add = None; let mut derive_clone = true; let mut partial = None; + let mut ignore_schema = false; + let mut schema_name = None; + let mut rename_all = None; // Parse optional parameters while input.peek(Token![,]) { @@ -615,11 +1083,27 @@ impl Parse for SchemaTypeInput { partial = Some(PartialMode::All); } } + "ignore" => { + // bare `ignore` — skip Schema derive + ignore_schema = true; + } + "name" => { + // name = "CustomSchemaName" — custom OpenAPI schema name + input.parse::()?; + let name_lit: LitStr = input.parse()?; + schema_name = Some(name_lit.value()); + } + "rename_all" => { + // rename_all = "camelCase" — serde rename_all strategy + input.parse::()?; + let rename_all_lit: LitStr = input.parse()?; + rename_all = Some(rename_all_lit.value()); + } _ => { return Err(syn::Error::new( ident.span(), format!( - "unknown parameter: `{}`. Expected `omit`, `pick`, `rename`, `add`, `clone`, or `partial`", + "unknown parameter: `{}`. Expected `omit`, `pick`, `rename`, `add`, `clone`, `partial`, `ignore`, `name`, or `rename_all`", ident_str ), )); @@ -644,37 +1128,594 @@ impl Parse for SchemaTypeInput { add, derive_clone, partial, + ignore_schema, + schema_name, + rename_all, }) } } +/// Extract the module path from a type (excluding the type name itself). +/// e.g., `crate::models::memo::Model` → ["crate", "models", "memo"] +fn extract_module_path(ty: &Type) -> Vec { + match ty { + Type::Path(type_path) => { + let segments: Vec = type_path + .path + .segments + .iter() + .map(|s| s.ident.to_string()) + .collect(); + // Return all but the last segment (which is the type name) + if segments.len() > 1 { + segments[..segments.len() - 1].to_vec() + } else { + vec![] + } + } + _ => vec![], + } +} + +/// Detect circular reference fields in a related schema. +/// +/// When generating `MemoSchema.user`, we need to check if `UserSchema` has any fields +/// that reference back to `MemoSchema` (e.g., `memos: Vec`). +/// +/// Returns a list of field names that would create circular references. +fn detect_circular_fields( + _source_schema_name: &str, + source_module_path: &[String], + related_schema_def: &str, +) -> Vec { + let mut circular_fields = Vec::new(); + + // Parse the related schema definition + let Ok(parsed) = syn::parse_str::(related_schema_def) else { + return circular_fields; + }; + + // Get the source module name (e.g., "memo" from ["crate", "models", "memo"]) + let source_module = source_module_path.last().map(|s| s.as_str()).unwrap_or(""); + + if let syn::Fields::Named(fields_named) = &parsed.fields { + for field in &fields_named.named { + let Some(field_ident) = &field.ident else { + continue; + }; + let field_name = field_ident.to_string(); + + // Check if this field's type references the source schema + let field_ty = &field.ty; + let ty_str = quote::quote!(#field_ty).to_string(); + + // Normalize whitespace: quote!() produces "foo :: bar" instead of "foo::bar" + // Remove all whitespace to make pattern matching reliable + let ty_str_normalized = ty_str.replace(' ', ""); + + // Check for patterns like: + // - Vec or Vec + // - Box or Box + // - Option> + // - HasMany + // - HasOne + // - BelongsTo + let is_circular = ty_str_normalized.contains(&format!("{}::Schema", source_module)) + || ty_str_normalized.contains(&format!("{}::Entity", source_module)) + || ty_str_normalized + .contains(&format!("{}Schema", capitalize_first(source_module))); + + if is_circular { + circular_fields.push(field_name); + } + } + } + + circular_fields +} + +/// Capitalize the first letter of a string. +fn capitalize_first(s: &str) -> String { + let mut chars = s.chars(); + match chars.next() { + None => String::new(), + Some(c) => c.to_uppercase().collect::() + chars.as_str(), + } +} + +/// Generate inline struct construction for a related schema, excluding circular fields. +/// +/// Instead of `>::from(r)`, generates: +/// ```ignore +/// user::Schema { +/// id: r.id, +/// name: r.name, +/// memos: vec![], // circular field - use default +/// } +/// ``` +fn generate_inline_struct_construction( + schema_path: &TokenStream, + related_schema_def: &str, + circular_fields: &[String], + var_name: &str, +) -> TokenStream { + // Parse the related schema definition + let Ok(parsed) = syn::parse_str::(related_schema_def) else { + // Fallback to From::from if parsing fails + let var_ident = syn::Ident::new(var_name, proc_macro2::Span::call_site()); + return quote! { <#schema_path as From<_>>::from(#var_ident) }; + }; + + let var_ident = syn::Ident::new(var_name, proc_macro2::Span::call_site()); + + // Get the named fields for FK checking + let fields_named = match &parsed.fields { + syn::Fields::Named(f) => f, + _ => { + return quote! { <#schema_path as From<_>>::from(#var_ident) }; + } + }; + + let field_assignments: Vec = fields_named + .named + .iter() + .filter_map(|field| { + let field_ident = field.ident.as_ref()?; + let field_name = field_ident.to_string(); + + // Skip fields marked with serde(skip) + if extract_skip(&field.attrs) { + return None; + } + + if circular_fields.contains(&field_name) || is_seaorm_relation_type(&field.ty) { + // Circular field or relation field - generate appropriate default + // based on the SeaORM relation type + Some(generate_default_for_relation_field( + &field.ty, + field_ident, + &field.attrs, + fields_named, + )) + } else { + // Regular field - copy from model + Some(quote! { #field_ident: #var_ident.#field_ident }) + } + }) + .collect(); + + quote! { + #schema_path { + #(#field_assignments),* + } + } +} + +/// Check if a circular relation field in the related schema is required (Box) or optional (Option>). +/// +/// Returns true if the circular relation is required and needs a parent stub. +fn is_circular_relation_required(related_model_def: &str, circular_field_name: &str) -> bool { + let Ok(parsed) = syn::parse_str::(related_model_def) else { + return false; + }; + + if let syn::Fields::Named(fields_named) = &parsed.fields { + for field in &fields_named.named { + let Some(field_ident) = &field.ident else { + continue; + }; + if field_ident.to_string() != circular_field_name { + continue; + } + + // Check if this is a HasOne/BelongsTo with required FK + let ty_str = quote::quote!(#field.ty).to_string().replace(' ', ""); + if ty_str.contains("HasOne<") || ty_str.contains("BelongsTo<") { + // Check FK field optionality + let fk_field = extract_belongs_to_from_field(&field.attrs); + if let Some(fk) = fk_field { + // Find FK field and check if it's Option + for f in &fields_named.named { + if f.ident.as_ref().map(|i| i.to_string()) == Some(fk.clone()) { + return !is_option_type(&f.ty); + } + } + } + } + } + } + false +} + +/// Generate a default value for a SeaORM relation field in inline construction. +/// +/// - `HasMany` → `vec![]` +/// - `HasOne`/`BelongsTo` with optional FK → `None` +/// - `HasOne`/`BelongsTo` with required FK → needs parent stub (handled separately) +fn generate_default_for_relation_field( + ty: &Type, + field_ident: &syn::Ident, + field_attrs: &[syn::Attribute], + all_fields: &syn::FieldsNamed, +) -> TokenStream { + let ty_str = quote::quote!(#ty).to_string().replace(' ', ""); + + // Check the SeaORM relation type + if ty_str.contains("HasMany<") { + // HasMany → Vec → empty vec + quote! { #field_ident: vec![] } + } else if ty_str.contains("HasOne<") || ty_str.contains("BelongsTo<") { + // Check FK field optionality + let fk_field = extract_belongs_to_from_field(field_attrs); + let is_optional = fk_field + .as_ref() + .map(|fk| { + all_fields.named.iter().any(|f| { + f.ident.as_ref().map(|i| i.to_string()) == Some(fk.clone()) + && is_option_type(&f.ty) + }) + }) + .unwrap_or(true); + + if is_optional { + // Option> → None + quote! { #field_ident: None } + } else { + // Box (required) → use __parent_stub__ + // This variable will be defined by the caller when needed + quote! { #field_ident: Box::new(__parent_stub__.clone()) } + } + } else { + // Unknown relation type - try Default::default() + quote! { #field_ident: Default::default() } + } +} + +/// Generate `from_model` impl for SeaORM Model WITH relations (async version). +/// +/// When circular references are detected, generates inline struct construction +/// that excludes circular fields (sets them to default values). +/// +/// ```ignore +/// impl NewType { +/// pub async fn from_model( +/// model: SourceType, +/// db: &sea_orm::DatabaseConnection, +/// ) -> Result { +/// // Load related entities +/// let user = model.find_related(user::Entity).one(db).await?; +/// let tags = model.find_related(tag::Entity).all(db).await?; +/// +/// Ok(Self { +/// id: model.id, +/// // Inline construction with circular field defaulted: +/// user: user.map(|r| Box::new(user::Schema { id: r.id, memos: vec![], ... })), +/// tags: tags.into_iter().map(|r| tag::Schema { ... }).collect(), +/// }) +/// } +/// } +/// ``` +fn generate_from_model_with_relations( + new_type_name: &syn::Ident, + source_type: &Type, + field_mappings: &[(syn::Ident, syn::Ident, bool, bool)], + relation_fields: &[RelationFieldInfo], + source_module_path: &[String], + _schema_storage: &[StructMetadata], +) -> TokenStream { + // Build relation loading statements + let relation_loads: Vec = relation_fields + .iter() + .map(|rel| { + let field_name = &rel.field_name; + let entity_path = + build_entity_path_from_schema_path(&rel.schema_path, source_module_path); + + match rel.relation_type.as_str() { + "HasOne" | "BelongsTo" => { + // Load single related entity + quote! { + let #field_name = model.find_related(#entity_path).one(db).await?; + } + } + "HasMany" => { + // Load multiple related entities + quote! { + let #field_name = model.find_related(#entity_path).all(db).await?; + } + } + _ => quote! {}, + } + }) + .collect(); + + // Check if we need a parent stub for HasMany relations with required circular back-refs + // This is needed when: UserSchema.memos has MemoSchema which has required user: Box + let needs_parent_stub = relation_fields.iter().any(|rel| { + if rel.relation_type != "HasMany" { + return false; + } + let schema_path_str = rel.schema_path.to_string().replace(' ', ""); + let model_path_str = schema_path_str.replace("::Schema", "::Model"); + let related_model = find_struct_from_schema_path(&model_path_str); + + if let Some(ref model) = related_model { + let circular_fields = detect_circular_fields( + new_type_name.to_string().as_str(), + source_module_path, + &model.definition, + ); + // Check if any circular field is a required relation + circular_fields + .iter() + .any(|cf| is_circular_relation_required(&model.definition, cf)) + } else { + false + } + }); + + // Generate parent stub field assignments (non-relation fields from model) + let parent_stub_fields: Vec = if needs_parent_stub { + field_mappings + .iter() + .map(|(new_ident, source_ident, _wrapped, is_relation)| { + if *is_relation { + // For relation fields in stub, use defaults + if let Some(rel) = relation_fields + .iter() + .find(|r| &r.field_name == source_ident) + { + match rel.relation_type.as_str() { + "HasMany" => quote! { #new_ident: vec![] }, + _ if rel.is_optional => quote! { #new_ident: None }, + // Required single relations in parent stub - this shouldn't happen + // as we're creating stub to break circular ref + _ => quote! { #new_ident: None }, + } + } else { + quote! { #new_ident: Default::default() } + } + } else { + // Regular field - clone from model + quote! { #new_ident: model.#source_ident.clone() } + } + }) + .collect() + } else { + vec![] + }; + + // Build field assignments + // For relation fields, check for circular references and use inline construction if needed + let field_assignments: Vec = field_mappings + .iter() + .map(|(new_ident, source_ident, wrapped, is_relation)| { + if *is_relation { + // Find the relation info for this field + if let Some(rel) = relation_fields.iter().find(|r| &r.field_name == source_ident) { + let schema_path = &rel.schema_path; + + // Try to find the related MODEL definition to check for circular refs + // The schema_path is like "crate::models::user::Schema", but the actual + // struct is "Model" in the same module. We need to look up the Model + // to see if it has relations pointing back to us. + let schema_path_str = schema_path.to_string().replace(' ', ""); + + // Convert schema path to model path: Schema -> Model + let model_path_str = schema_path_str.replace("::Schema", "::Model"); + + // Try to find the related Model definition from file + let related_model_from_file = find_struct_from_schema_path(&model_path_str); + + // Get the definition string + let related_def_str = related_model_from_file + .as_ref() + .map(|s| s.definition.as_str()) + .unwrap_or(""); + + // Check for circular references + // The source module path tells us what module we're in (e.g., ["crate", "models", "memo"]) + // We need to check if the related model has any relation fields pointing back to our module + let circular_fields = detect_circular_fields( + new_type_name.to_string().as_str(), + source_module_path, + related_def_str, + ); + + let has_circular = !circular_fields.is_empty(); + + match rel.relation_type.as_str() { + "HasOne" | "BelongsTo" => { + if has_circular { + // Use inline construction to break circular ref + let inline_construct = generate_inline_struct_construction( + schema_path, + related_def_str, + &circular_fields, + "r", + ); + if rel.is_optional { + quote! { + #new_ident: #source_ident.map(|r| Box::new(#inline_construct)) + } + } else { + quote! { + #new_ident: Box::new({ + let r = #source_ident.ok_or_else(|| sea_orm::DbErr::RecordNotFound( + format!("Required relation '{}' not found", stringify!(#source_ident)) + ))?; + #inline_construct + }) + } + } + } else { + // No circular ref - use From::from() + if rel.is_optional { + quote! { + #new_ident: #source_ident.map(|r| Box::new(<#schema_path as From<_>>::from(r))) + } + } else { + quote! { + #new_ident: Box::new(<#schema_path as From<_>>::from( + #source_ident.ok_or_else(|| sea_orm::DbErr::RecordNotFound( + format!("Required relation '{}' not found", stringify!(#source_ident)) + ))? + )) + } + } + } + } + "HasMany" => { + if has_circular { + // Use inline construction to break circular ref + let inline_construct = generate_inline_struct_construction( + schema_path, + related_def_str, + &circular_fields, + "r", + ); + quote! { + #new_ident: #source_ident.into_iter().map(|r| #inline_construct).collect() + } + } else { + quote! { + #new_ident: #source_ident.into_iter().map(|r| <#schema_path as From<_>>::from(r)).collect() + } + } + } + _ => quote! { #new_ident: Default::default() }, + } + } else { + quote! { #new_ident: Default::default() } + } + } else if *wrapped { + quote! { #new_ident: Some(model.#source_ident) } + } else { + quote! { #new_ident: model.#source_ident } + } + }) + .collect(); + + // Circular references are now handled automatically via inline construction + // For HasMany with required circular back-refs, we create a parent stub first + + // Generate parent stub definition if needed + let parent_stub_def = if needs_parent_stub { + quote! { + #[allow(unused_variables)] + let __parent_stub__ = Self { + #(#parent_stub_fields),* + }; + } + } else { + quote! {} + }; + + quote! { + impl #new_type_name { + pub async fn from_model( + model: #source_type, + db: &sea_orm::DatabaseConnection, + ) -> Result { + use sea_orm::ModelTrait; + + #(#relation_loads)* + + #parent_stub_def + + Ok(Self { + #(#field_assignments),* + }) + } + } + } +} + +/// Build Entity path from Schema path. +/// e.g., `crate::models::user::Schema` -> `crate::models::user::Entity` +fn build_entity_path_from_schema_path( + schema_path: &TokenStream, + _source_module_path: &[String], +) -> TokenStream { + // Parse the schema path to extract segments + let path_str = schema_path.to_string(); + let segments: Vec<&str> = path_str.split("::").map(|s| s.trim()).collect(); + + // Replace "Schema" with "Entity" in the last segment + let entity_segments: Vec = segments + .iter() + .map(|s| { + if *s == "Schema" { + "Entity".to_string() + } else { + s.to_string() + } + }) + .collect(); + + // Build the path tokens + let path_idents: Vec = entity_segments + .iter() + .map(|s| syn::Ident::new(s, proc_macro2::Span::call_site())) + .collect(); + + quote! { #(#path_idents)::* } +} + /// Generate a new struct type from an existing type with field filtering +/// +/// Returns (TokenStream, Option) where the metadata is returned +/// when a custom `name` is provided (for direct registration in SCHEMA_STORAGE). pub fn generate_schema_type_code( input: &SchemaTypeInput, schema_storage: &[StructMetadata], -) -> Result { +) -> Result<(TokenStream, Option), syn::Error> { // Extract type name from the source Type let source_type_name = extract_type_name(&input.source_type)?; - // Find struct definition in storage first (for same-file structs) + // Extract the module path for resolving relative paths in relation types + let source_module_path = extract_module_path(&input.source_type); + + // Find struct definition - lookup order depends on whether path is qualified + // For qualified paths (crate::models::memo::Model), try file lookup FIRST + // to avoid name collisions when multiple modules have same struct name (e.g., Model) let struct_def_owned: StructMetadata; - let struct_def = if let Some(found) = schema_storage.iter().find(|s| s.name == source_type_name) - { - found - } else if let Some(found) = find_struct_from_path(&input.source_type) { - // Try to find from file path (for cross-file structs like models::memo::Model) - struct_def_owned = found; - &struct_def_owned + let struct_def = if is_qualified_path(&input.source_type) { + // Qualified path: try file lookup first, then storage + if let Some(found) = find_struct_from_path(&input.source_type) { + struct_def_owned = found; + &struct_def_owned + } else if let Some(found) = schema_storage.iter().find(|s| s.name == source_type_name) { + found + } else { + return Err(syn::Error::new_spanned( + &input.source_type, + format!( + "type `{}` not found. Either:\n\ + 1. Use #[derive(Schema)] in the same file\n\ + 2. Use full module path like `crate::models::memo::Model` to reference a struct from another file", + source_type_name + ), + )); + } } else { - return Err(syn::Error::new_spanned( - &input.source_type, - format!( - "type `{}` not found. Either:\n\ - 1. Use #[derive(Schema)] in the same file\n\ - 2. Use full module path like `crate::models::memo::Model` to reference a struct from another file", - source_type_name - ), - )); + // Simple name: try storage first (for same-file structs), then file lookup + if let Some(found) = schema_storage.iter().find(|s| s.name == source_type_name) { + found + } else if let Some(found) = find_struct_from_path(&input.source_type) { + struct_def_owned = found; + &struct_def_owned + } else { + return Err(syn::Error::new_spanned( + &input.source_type, + format!( + "type `{}` not found. Either:\n\ + 1. Use #[derive(Schema)] in the same file\n\ + 2. Use full module path like `crate::models::memo::Model` to reference a struct from another file", + source_type_name + ), + )); + } }; // Parse the struct definition @@ -689,6 +1730,7 @@ pub fn generate_schema_type_code( })?; // Extract all field names from source struct for validation + // Include relation fields since they can be converted to Schema types let source_field_names: HashSet = if let syn::Fields::Named(fields_named) = &parsed_struct.fields { fields_named @@ -790,18 +1832,47 @@ pub fn generate_schema_type_code( .into_iter() .collect(); - // Extract serde attributes from source struct - let serde_attrs: Vec<_> = parsed_struct + // Extract serde attributes from source struct, excluding rename_all (we'll handle it separately) + let serde_attrs_without_rename_all: Vec<_> = parsed_struct .attrs .iter() - .filter(|attr| attr.path().is_ident("serde")) + .filter(|attr| { + if !attr.path().is_ident("serde") { + return false; + } + // Check if this serde attr contains rename_all + let mut has_rename_all = false; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("rename_all") { + has_rename_all = true; + } + Ok(()) + }); + !has_rename_all + }) .collect(); + // Determine the rename_all strategy: + // 1. If input.rename_all is specified, use it + // 2. Else if source has rename_all, use it + // 3. Else default to "camelCase" + let effective_rename_all = if let Some(ref ra) = input.rename_all { + ra.clone() + } else { + // Check source struct for existing rename_all + extract_rename_all(&parsed_struct.attrs).unwrap_or_else(|| "camelCase".to_string()) + }; + + // Check if source is a SeaORM Model + let is_source_seaorm_model = is_seaorm_model(&parsed_struct); + // Generate new struct with filtered fields let new_type_name = &input.new_type; let mut field_tokens = Vec::new(); - // Track field mappings for From impl: (new_field_ident, source_field_ident, wrapped_in_option) - let mut field_mappings: Vec<(syn::Ident, syn::Ident, bool)> = Vec::new(); + // Track field mappings for From impl: (new_field_ident, source_field_ident, wrapped_in_option, is_relation) + let mut field_mappings: Vec<(syn::Ident, syn::Ident, bool, bool)> = Vec::new(); + // Track relation field info for from_model generation + let mut relation_fields: Vec = Vec::new(); if let syn::Fields::Named(fields_named) = &parsed_struct.fields { for field in &fields_named.named { @@ -821,15 +1892,41 @@ pub fn generate_schema_type_code( continue; } + // Check if this is a SeaORM relation type + let is_relation = is_seaorm_relation_type(&field.ty); + // Get field components, applying partial wrapping if needed let original_ty = &field.ty; let should_wrap_option = (partial_all || partial_set.contains(&rust_field_name)) - && !is_option_type(original_ty); - let field_ty: Box = if should_wrap_option { - Box::new(quote! { Option<#original_ty> }) - } else { - Box::new(quote! { #original_ty }) - }; + && !is_option_type(original_ty) + && !is_relation; // Don't wrap relations in another Option + + // Determine field type: convert relation types to Schema types + let (field_ty, relation_info): (Box, Option) = + if is_relation { + // Convert HasOne/HasMany/BelongsTo to Schema type + if let Some((converted, rel_info)) = convert_relation_type_to_schema_with_info( + original_ty, + &field.attrs, + &parsed_struct, + &source_module_path, + field.ident.clone().unwrap(), + ) { + (Box::new(converted), Some(rel_info)) + } else { + // Fallback: skip if conversion fails + continue; + } + } else if should_wrap_option { + (Box::new(quote! { Option<#original_ty> }), None) + } else { + (Box::new(quote! { #original_ty }), None) + }; + + // Collect relation info + if let Some(info) = relation_info { + relation_fields.push(info); + } let vis = &field.vis; let source_field_ident = field.ident.clone().unwrap(); @@ -875,7 +1972,12 @@ pub fn generate_schema_type_code( }); // Track mapping: new field name <- source field name - field_mappings.push((new_field_ident, source_field_ident, should_wrap_option)); + field_mappings.push(( + new_field_ident, + source_field_ident, + should_wrap_option, + is_relation, + )); } else { // No rename, keep field with only serde attrs let field_ident = field.ident.clone().unwrap(); @@ -886,7 +1988,12 @@ pub fn generate_schema_type_code( }); // Track mapping: same name - field_mappings.push((field_ident.clone(), field_ident, should_wrap_option)); + field_mappings.push(( + field_ident.clone(), + field_ident, + should_wrap_option, + is_relation, + )); } } } @@ -908,12 +2015,30 @@ pub fn generate_schema_type_code( quote! {} }; - // Generate From impl only if `add` is not used (can't auto-populate added fields) + // Conditionally include Schema derive based on ignore_schema flag + // Also generate #[schema(name = "...")] attribute if custom name is provided AND Schema is derived + let (schema_derive, schema_name_attr) = if input.ignore_schema { + (quote! {}, quote! {}) + } else if let Some(ref name) = input.schema_name { + ( + quote! { vespera::Schema }, + quote! { #[schema(name = #name)] }, + ) + } else { + (quote! { vespera::Schema }, quote! {}) + }; + + // Check if there are any relation fields + let has_relation_fields = field_mappings.iter().any(|(_, _, _, is_rel)| *is_rel); + + // Generate From impl only if: + // 1. `add` is not used (can't auto-populate added fields) + // 2. There are no relation fields (relation fields don't exist on source Model) let source_type = &input.source_type; - let from_impl = if input.add.is_none() { + let from_impl = if input.add.is_none() && !has_relation_fields { let field_assignments: Vec<_> = field_mappings .iter() - .map(|(new_ident, source_ident, wrapped)| { + .map(|(new_ident, source_ident, wrapped, _is_relation)| { if *wrapped { quote! { #new_ident: Some(source.#source_ident) } } else { @@ -935,16 +2060,56 @@ pub fn generate_schema_type_code( quote! {} }; + // Generate from_model impl for SeaORM Models WITH relations + // - No relations: Use `From` trait (generated above) + // - Has relations: async fn from_model(model: Model, db: &DatabaseConnection) -> Result + let from_model_impl = if is_source_seaorm_model && input.add.is_none() && has_relation_fields { + generate_from_model_with_relations( + new_type_name, + source_type, + &field_mappings, + &relation_fields, + &source_module_path, + schema_storage, + ) + } else { + quote! {} + }; + // Generate the new struct - Ok(quote! { - #[derive(serde::Serialize, serde::Deserialize, #clone_derive vespera::Schema)] - #(#serde_attrs)* + let generated_tokens = quote! { + #[derive(serde::Serialize, serde::Deserialize, #clone_derive #schema_derive)] + #schema_name_attr + #[serde(rename_all = #effective_rename_all)] + #(#serde_attrs_without_rename_all)* pub struct #new_type_name { #(#field_tokens),* } #from_impl - }) + #from_model_impl + }; + + // If custom name is provided, create metadata for direct registration + // This ensures the schema appears in OpenAPI even when `ignore` is set + let metadata = if let Some(ref custom_name) = input.schema_name { + // Build struct definition string for metadata (without derives/attrs for parsing) + let struct_def = quote! { + #[serde(rename_all = #effective_rename_all)] + #(#serde_attrs_without_rename_all)* + pub struct #new_type_name { + #(#field_tokens),* + } + }; + Some(StructMetadata::new( + custom_name.clone(), + struct_def.to_string(), + )) + } else { + None + }; + + Ok((generated_tokens, metadata)) } #[cfg(test)] @@ -1174,7 +2339,8 @@ mod tests { let result = generate_schema_type_code(&input, &storage); assert!(result.is_ok()); - let output = result.unwrap().to_string(); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); // id and name should be wrapped in Option, bio already Option stays unchanged assert!(output.contains("Option < i32 >")); assert!(output.contains("Option < String >")); @@ -1192,7 +2358,8 @@ mod tests { let result = generate_schema_type_code(&input, &storage); assert!(result.is_ok()); - let output = result.unwrap().to_string(); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); // name should be Option, but id and email should remain unwrapped assert!(output.contains("UpdateUser")); } @@ -1226,7 +2393,8 @@ mod tests { let result = generate_schema_type_code(&input, &storage); assert!(result.is_ok()); - let output = result.unwrap().to_string(); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); // From impl should wrap values in Some() assert!(output.contains("Some (source . id)")); assert!(output.contains("Some (source . name)")); @@ -1599,7 +2767,8 @@ mod tests { let result = generate_schema_type_code(&input, &storage); assert!(result.is_ok()); - let output = result.unwrap().to_string(); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); assert!(output.contains("CreateUser")); assert!(output.contains("name")); } @@ -1616,7 +2785,8 @@ mod tests { let result = generate_schema_type_code(&input, &storage); assert!(result.is_ok()); - let output = result.unwrap().to_string(); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); assert!(output.contains("SafeUser")); // Should not contain password assert!(!output.contains("password")); @@ -1634,7 +2804,8 @@ mod tests { let result = generate_schema_type_code(&input, &storage); assert!(result.is_ok()); - let output = result.unwrap().to_string(); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); assert!(output.contains("UserWithExtra")); assert!(output.contains("extra")); } @@ -1652,7 +2823,8 @@ mod tests { let result = generate_schema_type_code(&input, &storage); assert!(result.is_ok()); - let output = result.unwrap().to_string(); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); assert!(output.contains("impl From")); assert!(output.contains("for UserResponse")); } @@ -1670,7 +2842,8 @@ mod tests { let result = generate_schema_type_code(&input, &storage); assert!(result.is_ok()); - let output = result.unwrap().to_string(); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); // Should NOT contain From impl when add is used assert!(!output.contains("impl From")); } diff --git a/examples/axum-example/models/memo.vespertide.json b/examples/axum-example/models/memo.vespertide.json new file mode 100644 index 0000000..b63e813 --- /dev/null +++ b/examples/axum-example/models/memo.vespertide.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://raw.githubusercontent.com/dev-five-git/vespertide/refs/heads/main/schemas/model.schema.json", + "name": "memo", + "columns": [ + { "name": "id", "type": "integer", "nullable": false, "primary_key": { "auto_increment": true } }, + { + "name": "user_id", + "type": "integer", + "nullable": false, + "foreign_key": { "ref_table": "user", "ref_columns": ["id"], "on_delete": "cascade" }, + "index": true + }, + { "name": "title", "type": { "kind": "varchar", "length": 200 }, "nullable": false }, + { "name": "content", "type": "text", "nullable": false }, + { "name": "created_at", "type": "timestamptz", "nullable": false, "default": "NOW()" }, + { "name": "updated_at", "type": "timestamptz", "nullable": false, "default": "NOW()" } + ] +} diff --git a/examples/axum-example/models/user.vespertide.json b/examples/axum-example/models/user.vespertide.json new file mode 100644 index 0000000..e08d176 --- /dev/null +++ b/examples/axum-example/models/user.vespertide.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://raw.githubusercontent.com/dev-five-git/vespertide/refs/heads/main/schemas/model.schema.json", + "name": "user", + "columns": [ + { "name": "id", "type": "integer", "nullable": false, "primary_key": { "auto_increment": true } }, + { "name": "email", "type": "text", "nullable": false, "unique": true, "index": true }, + { "name": "name", "type": { "kind": "varchar", "length": 100 }, "nullable": false }, + { "name": "created_at", "type": "timestamptz", "nullable": false, "default": "NOW()" }, + { "name": "updated_at", "type": "timestamptz", "nullable": false, "default": "NOW()" } + ] +} diff --git a/examples/axum-example/openapi.json b/examples/axum-example/openapi.json index f77e292..3e423b6 100644 --- a/examples/axum-example/openapi.json +++ b/examples/axum-example/openapi.json @@ -703,6 +703,33 @@ } } }, + "/memos/{id}/rel": { + "get": { + "operationId": "get_memo_rel", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemoResponseRel" + } + } + } + } + } + } + }, "/no-schema-query": { "get": { "operationId": "mod_file_with_no_schema_query", @@ -987,7 +1014,7 @@ "/third": { "get": { "operationId": "third_root_endpoint", - "description": "Third app root endpoint", + "description": "/ Third app root endpoint", "responses": { "200": { "description": "Successful response", @@ -1008,7 +1035,7 @@ "tags": [ "third" ], - "description": "Third app hello endpoint", + "description": "/ Third app hello endpoint", "responses": { "200": { "description": "Successful response", @@ -1560,7 +1587,7 @@ "CreateUserWithMeta": { "type": "object", "properties": { - "created_at": { + "createdAt": { "type": "string", "nullable": true }, @@ -1570,14 +1597,14 @@ "name": { "type": "string" }, - "request_id": { + "requestId": { "type": "string" } }, "required": [ "name", "email", - "request_id" + "requestId" ] }, "Enum": { @@ -1905,7 +1932,7 @@ "content": { "type": "string" }, - "created_at": { + "createdAt": { "type": "string", "format": "date-time" }, @@ -1914,12 +1941,105 @@ }, "title": { "type": "string" + }, + "userId": { + "type": "integer" } }, "required": [ "id", + "userId", "title", "content", + "createdAt" + ] + }, + "MemoResponseRel": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "user": { + "$ref": "#/components/schemas/UserSchema" + }, + "userId": { + "type": "integer" + } + }, + "required": [ + "id", + "userId", + "title", + "content", + "createdAt", + "user" + ] + }, + "MemoSchema": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "user": { + "$ref": "#/components/schemas/UserSchema" + }, + "userId": { + "type": "integer" + } + }, + "required": [ + "id", + "userId", + "title", + "content", + "createdAt", + "updatedAt", + "user" + ] + }, + "MemoSnakeCase": { + "type": "object", + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "integer" + }, + "user_id": { + "type": "integer" + } + }, + "required": [ + "id", + "user_id", "created_at" ] }, @@ -2231,6 +2351,42 @@ "email" ] }, + "UserSchema": { + "type": "object", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time" + }, + "email": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "memos": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MemoSchema" + } + }, + "name": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "email", + "name", + "createdAt", + "updatedAt", + "memos" + ] + }, "UserSummary": { "type": "object", "properties": { diff --git a/examples/axum-example/src/lib.rs b/examples/axum-example/src/lib.rs index f6768fa..9ce057e 100644 --- a/examples/axum-example/src/lib.rs +++ b/examples/axum-example/src/lib.rs @@ -3,12 +3,14 @@ mod routes; use std::sync::Arc; +use sea_orm::{Database, DatabaseConnection}; use serde::{Deserialize, Serialize}; use third::ThirdApp; use vespera::{Schema, axum, vespera}; pub struct AppState { pub config: String, + pub db: Arc, } #[derive(Serialize, Deserialize, Schema)] @@ -18,7 +20,8 @@ pub struct TestStruct { } /// Create the application router for testing -pub fn create_app() -> axum::Router { +pub async fn create_app() -> axum::Router { + let db = Database::connect("sqlite://:memory:").await.unwrap(); vespera!( openapi = ["examples/axum-example/openapi.json", "openapi.json"], docs_url = "/docs", @@ -27,11 +30,12 @@ pub fn create_app() -> axum::Router { ) .with_state(Arc::new(AppState { config: "test".to_string(), + db: Arc::new(db), })) } /// Create the application router with a layer for testing VesperaRouter::layer -pub fn create_app_with_layer() -> axum::Router { +pub async fn create_app_with_layer() -> axum::Router { use tower_http::cors::{Any, CorsLayer}; let cors = CorsLayer::new() @@ -39,6 +43,7 @@ pub fn create_app_with_layer() -> axum::Router { .allow_methods(Any) .allow_headers(Any); + let db = Database::connect("sqlite://:memory:").await.unwrap(); vespera!( openapi = ["examples/axum-example/openapi.json", "openapi.json"], docs_url = "/docs", @@ -48,5 +53,6 @@ pub fn create_app_with_layer() -> axum::Router { .layer(cors) .with_state(Arc::new(AppState { config: "test".to_string(), + db: Arc::new(db), })) } diff --git a/examples/axum-example/src/main.rs b/examples/axum-example/src/main.rs index fdcebe2..b95bb98 100644 --- a/examples/axum-example/src/main.rs +++ b/examples/axum-example/src/main.rs @@ -3,7 +3,7 @@ use vespera::axum; #[tokio::main] async fn main() { - let app = create_app(); + let app = create_app().await; let addr = std::net::SocketAddr::from(([0, 0, 0, 0], 3000)); let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); diff --git a/examples/axum-example/src/models/memo.rs b/examples/axum-example/src/models/memo.rs index 879fea9..1a06592 100644 --- a/examples/axum-example/src/models/memo.rs +++ b/examples/axum-example/src/models/memo.rs @@ -1,21 +1,27 @@ use sea_orm::entity::prelude::*; -use serde::{Deserialize, Serialize}; -/// Memo storage for example-memo-plugin #[sea_orm::model] -#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] -#[sea_orm(table_name = "memp_memos")] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "memo")] pub struct Model { #[sea_orm(primary_key)] pub id: i32, + #[sea_orm(indexed)] + pub user_id: i32, pub title: String, pub content: String, - #[sea_orm(indexed, default_value = "NOW()")] + #[sea_orm(default_value = "NOW()")] pub created_at: DateTimeWithTimeZone, #[sea_orm(default_value = "NOW()")] pub updated_at: DateTimeWithTimeZone, + #[sea_orm(belongs_to, from = "user_id", to = "id")] + pub user: HasOne, } +// Schema WITH user relation - has async from_model(model, db) method +// Circular refs auto-handled: when loading user, its memos field is set to vec![] +vespera::schema_type!(Schema from crate::models::memo::Model, name = "MemoSchema"); + // Index definitions (SeaORM uses Statement builders externally) -// (unnamed) on [created_at] +// (unnamed) on [user_id] impl ActiveModelBehavior for ActiveModel {} diff --git a/examples/axum-example/src/models/mod.rs b/examples/axum-example/src/models/mod.rs index 80d0261..d51fe8f 100644 --- a/examples/axum-example/src/models/mod.rs +++ b/examples/axum-example/src/models/mod.rs @@ -1 +1,2 @@ pub mod memo; +pub mod user; diff --git a/examples/axum-example/src/models/user.rs b/examples/axum-example/src/models/user.rs new file mode 100644 index 0000000..47c8140 --- /dev/null +++ b/examples/axum-example/src/models/user.rs @@ -0,0 +1,27 @@ +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(unique)] + pub email: String, + pub name: String, + #[sea_orm(default_value = "NOW()")] + pub created_at: DateTimeWithTimeZone, + #[sea_orm(default_value = "NOW()")] + pub updated_at: DateTimeWithTimeZone, + #[sea_orm(has_many)] + pub memos: HasMany, +} + +// Schema WITH memos relation - circular refs are auto-handled +// When embedded in MemoSchema.user, the memos field will be defaulted to vec![] +// Custom OpenAPI name: "UserSchema" +vespera::schema_type!(Schema from crate::models::user::Model, name = "UserSchema"); + +// Index definitions (SeaORM uses Statement builders externally) +// (unnamed) on [email] +impl ActiveModelBehavior for ActiveModel {} diff --git a/examples/axum-example/src/routes/memos.rs b/examples/axum-example/src/routes/memos.rs index 256af2d..d1cb564 100644 --- a/examples/axum-example/src/routes/memos.rs +++ b/examples/axum-example/src/routes/memos.rs @@ -7,13 +7,17 @@ //! 3. Extract the struct definition //! 4. Generate the new type + From impl for easy conversion +use std::sync::Arc; + // Import types used by the source model that we want to include in generated structs -use sea_orm::entity::prelude::DateTimeWithTimeZone; +use sea_orm::{entity::prelude::DateTimeWithTimeZone}; use vespera::{ - axum::{Json, extract::Path}, + axum::{Json, extract::{Path, State}}, schema_type, }; +use crate::AppState; + // ============================================================================ // schema_type! generates request/response types from models in OTHER FILES // Also generates From impl when `add` is not used @@ -27,9 +31,14 @@ schema_type!(CreateMemoRequest from crate::models::memo::Model, pick = ["title", // NO From impl (because `add` is used - can't auto-populate added fields) schema_type!(UpdateMemoRequest from crate::models::memo::Model, pick = ["title", "content"], add = [("id": i32)]); -// Response type: all fields except updated_at -// Has From impl: crate::models::memo::Model -> MemoResponse -schema_type!(MemoResponse from crate::models::memo::Model, omit = ["updated_at"]); +// Response type: all fields except updated_at and user relation +// Has From impl since we omit the relation field +schema_type!(MemoResponse from crate::models::memo::Model, omit = ["updated_at", "user"]); + +schema_type!(MemoResponseRel from crate::models::memo::Model, omit = ["updated_at"]); + +// Test rename_all override: use snake_case instead of default camelCase +schema_type!(MemoSnakeCase from crate::models::memo::Model, pick = ["id", "user_id", "created_at"], rename_all = "snake_case"); /// Create a new memo #[vespera::route(post)] @@ -59,6 +68,7 @@ pub async fn get_memo(Path(id): Path) -> Json { // schema_type! generates From for MemoResponse, so .into() works let model = crate::models::memo::Model { id, + user_id: 1, // Example user ID title: "Test Memo".to_string(), content: "This is test content".to_string(), created_at: DateTimeWithTimeZone::default(), @@ -67,8 +77,23 @@ pub async fn get_memo(Path(id): Path) -> Json { Json(model.into()) } +#[vespera::route(get, path = "/{id}/rel")] +pub async fn get_memo_rel(Path(id): Path, State(app_state): State>) -> Json { + // In real app, this would be a DB query returning Model + // schema_type! generates From for MemoResponse, so .into() works + let model = crate::models::memo::Model { + id, + user_id: 1, // Example user ID + title: "Test Memo".to_string(), + content: "This is test content".to_string(), + created_at: DateTimeWithTimeZone::default(), + updated_at: DateTimeWithTimeZone::default(), + }; + Json(MemoResponseRel::from_model(model, app_state.db.as_ref()).await.unwrap()) +} + /// Get memo response format #[vespera::route(get, path = "/format")] pub async fn get_memo_format() -> &'static str { - "MemoResponse has: id, title, content, created_at (no updated_at)" + "MemoResponse has: id, user_id, title, content, created_at (no updated_at, no user relation)" } diff --git a/examples/axum-example/tests/integration_test.rs b/examples/axum-example/tests/integration_test.rs index 8c85bcf..2a124b8 100644 --- a/examples/axum-example/tests/integration_test.rs +++ b/examples/axum-example/tests/integration_test.rs @@ -6,7 +6,7 @@ use vespera::{Schema, schema}; #[tokio::test] async fn test_health_endpoint() { - let app = create_app(); + let app = create_app().await; let server = TestServer::new(app).unwrap(); let response = server.get("/health").await; @@ -17,7 +17,7 @@ async fn test_health_endpoint() { #[tokio::test] async fn test_mod_file_endpoint() { - let app = create_app(); + let app = create_app().await; let server = TestServer::new(app).unwrap(); let response = server.get("/hello").await; @@ -33,7 +33,7 @@ async fn test_mod_file_endpoint() { #[tokio::test] async fn test_get_users() { - let app = create_app(); + let app = create_app().await; let server = TestServer::new(app).unwrap(); let response = server.get("/users").await; @@ -52,7 +52,7 @@ async fn test_get_users() { #[tokio::test] async fn test_get_user_by_id() { - let app = create_app(); + let app = create_app().await; let server = TestServer::new(app).unwrap(); let response = server.get("/users/42").await; @@ -67,7 +67,7 @@ async fn test_get_user_by_id() { #[tokio::test] async fn test_create_user() { - let app = create_app(); + let app = create_app().await; let server = TestServer::new(app).unwrap(); let new_user = json!({ @@ -87,7 +87,7 @@ async fn test_create_user() { #[tokio::test] async fn test_get_nonexistent_user() { - let app = create_app(); + let app = create_app().await; let server = TestServer::new(app).unwrap(); let response = server.get("/users/999").await; @@ -99,7 +99,7 @@ async fn test_get_nonexistent_user() { #[tokio::test] async fn test_prefix_variable() { - let app = create_app(); + let app = create_app().await; let server = TestServer::new(app).unwrap(); let response = server.get("/path/prefix/123").await; @@ -110,7 +110,7 @@ async fn test_prefix_variable() { #[tokio::test] async fn test_invalid_path() { - let app = create_app(); + let app = create_app().await; let server = TestServer::new(app).unwrap(); let response = server.get("/nonexistent").await; @@ -120,7 +120,7 @@ async fn test_invalid_path() { #[tokio::test] async fn test_mod_file_with_complex_struct_body() { - let app = create_app(); + let app = create_app().await; let server = TestServer::new(app).unwrap(); let complex_body = json!({ @@ -209,7 +209,7 @@ async fn test_mod_file_with_complex_struct_body() { #[tokio::test] async fn test_mod_file_with_complex_struct_body_with_rename() { - let app = create_app(); + let app = create_app().await; let server = TestServer::new(app).unwrap(); let complex_body = json!({ @@ -299,7 +299,7 @@ async fn test_mod_file_with_complex_struct_body_with_rename() { // Tests for merged routes from third app #[tokio::test] async fn test_third_app_root_endpoint() { - let app = create_app(); + let app = create_app().await; let server = TestServer::new(app).unwrap(); let response = server.get("/third").await; @@ -310,7 +310,7 @@ async fn test_third_app_root_endpoint() { #[tokio::test] async fn test_third_app_hello_endpoint() { - let app = create_app(); + let app = create_app().await; let server = TestServer::new(app).unwrap(); let response = server.get("/third/hello").await; @@ -321,7 +321,7 @@ async fn test_third_app_hello_endpoint() { #[tokio::test] async fn test_third_app_map_query_endpoint() { - let app = create_app(); + let app = create_app().await; let server = TestServer::new(app).unwrap(); let response = server.get("/third/map-query?name=test&age=25").await; @@ -332,7 +332,7 @@ async fn test_third_app_map_query_endpoint() { #[tokio::test] async fn test_third_app_map_query_with_optional() { - let app = create_app(); + let app = create_app().await; let server = TestServer::new(app).unwrap(); let response = server @@ -387,7 +387,7 @@ async fn test_openapi_contains_third_app_schemas() { // Test VesperaRouter::layer functionality #[tokio::test] async fn test_app_with_layer() { - let app = create_app_with_layer(); + let app = create_app_with_layer().await; let server = TestServer::new(app).unwrap(); // Test that routes still work with the layer applied @@ -610,7 +610,7 @@ fn test_schema_macro_omit_with_rust_field_name() { #[tokio::test] async fn test_get_user_dto_with_renamed_fields() { - let app = create_app(); + let app = create_app().await; let server = TestServer::new(app).unwrap(); let response = server.get("/users/dto/42").await; @@ -641,7 +641,7 @@ async fn test_get_user_dto_with_renamed_fields() { #[tokio::test] async fn test_create_user_with_meta_add_fields() { - let app = create_app(); + let app = create_app().await; let server = TestServer::new(app).unwrap(); // CreateUserWithMeta has: name, email (from User) + request_id, created_at (added) @@ -670,7 +670,7 @@ async fn test_create_user_with_meta_add_fields() { #[tokio::test] async fn test_memo_create_with_picked_fields() { - let app = create_app(); + let app = create_app().await; let server = TestServer::new(app).unwrap(); // CreateMemoRequest has only: title, content (picked from Memo) @@ -700,7 +700,7 @@ async fn test_memo_create_with_picked_fields() { #[tokio::test] async fn test_memo_update_with_added_id_field() { - let app = create_app(); + let app = create_app().await; let server = TestServer::new(app).unwrap(); // UpdateMemoRequest has: title, content (picked) + id (added) diff --git a/examples/axum-example/tests/snapshots/integration_test__openapi.snap b/examples/axum-example/tests/snapshots/integration_test__openapi.snap index 0b2af2b..1dabcc5 100644 --- a/examples/axum-example/tests/snapshots/integration_test__openapi.snap +++ b/examples/axum-example/tests/snapshots/integration_test__openapi.snap @@ -707,6 +707,33 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" } } }, + "/memos/{id}/rel": { + "get": { + "operationId": "get_memo_rel", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemoResponseRel" + } + } + } + } + } + } + }, "/no-schema-query": { "get": { "operationId": "mod_file_with_no_schema_query", @@ -1564,7 +1591,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "CreateUserWithMeta": { "type": "object", "properties": { - "created_at": { + "createdAt": { "type": "string", "nullable": true }, @@ -1574,14 +1601,14 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "name": { "type": "string" }, - "request_id": { + "requestId": { "type": "string" } }, "required": [ "name", "email", - "request_id" + "requestId" ] }, "Enum": { @@ -1909,7 +1936,35 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "content": { "type": "string" }, - "created_at": { + "createdAt": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "userId": { + "type": "integer" + } + }, + "required": [ + "id", + "userId", + "title", + "content", + "createdAt" + ] + }, + "MemoResponseRel": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "createdAt": { "type": "string", "format": "date-time" }, @@ -1918,13 +1973,58 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" }, "title": { "type": "string" + }, + "user": { + "$ref": "#/components/schemas/UserSchema" + }, + "userId": { + "type": "integer" } }, "required": [ "id", + "userId", "title", "content", - "created_at" + "createdAt", + "user" + ] + }, + "MemoSchema": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "user": { + "$ref": "#/components/schemas/UserSchema" + }, + "userId": { + "type": "integer" + } + }, + "required": [ + "id", + "userId", + "title", + "content", + "createdAt", + "updatedAt", + "user" ] }, "PaginatedResponse": { @@ -2235,6 +2335,42 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "email" ] }, + "UserSchema": { + "type": "object", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time" + }, + "email": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "memos": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MemoSchema" + } + }, + "name": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "email", + "name", + "createdAt", + "updatedAt", + "memos" + ] + }, "UserSummary": { "type": "object", "properties": { diff --git a/examples/axum-example/vespertide.json b/examples/axum-example/vespertide.json new file mode 100644 index 0000000..8779b17 --- /dev/null +++ b/examples/axum-example/vespertide.json @@ -0,0 +1,18 @@ +{ + "modelsDir": "models", + "migrationsDir": "migrations", + "tableNamingCase": "snake", + "columnNamingCase": "snake", + "modelFormat": "json", + "migrationFormat": "json", + "migrationFilenamePattern": "%04v_%m", + "modelExportDir": "src/models", + "seaorm": { + "extraEnumDerives": [ + "vespera::Schema" + ], + "extraModelDerives": [], + "enumNamingCase": "camel" + }, + "prefix": "" +} \ No newline at end of file diff --git a/openapi.json b/openapi.json index f77e292..3e423b6 100644 --- a/openapi.json +++ b/openapi.json @@ -703,6 +703,33 @@ } } }, + "/memos/{id}/rel": { + "get": { + "operationId": "get_memo_rel", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemoResponseRel" + } + } + } + } + } + } + }, "/no-schema-query": { "get": { "operationId": "mod_file_with_no_schema_query", @@ -987,7 +1014,7 @@ "/third": { "get": { "operationId": "third_root_endpoint", - "description": "Third app root endpoint", + "description": "/ Third app root endpoint", "responses": { "200": { "description": "Successful response", @@ -1008,7 +1035,7 @@ "tags": [ "third" ], - "description": "Third app hello endpoint", + "description": "/ Third app hello endpoint", "responses": { "200": { "description": "Successful response", @@ -1560,7 +1587,7 @@ "CreateUserWithMeta": { "type": "object", "properties": { - "created_at": { + "createdAt": { "type": "string", "nullable": true }, @@ -1570,14 +1597,14 @@ "name": { "type": "string" }, - "request_id": { + "requestId": { "type": "string" } }, "required": [ "name", "email", - "request_id" + "requestId" ] }, "Enum": { @@ -1905,7 +1932,7 @@ "content": { "type": "string" }, - "created_at": { + "createdAt": { "type": "string", "format": "date-time" }, @@ -1914,12 +1941,105 @@ }, "title": { "type": "string" + }, + "userId": { + "type": "integer" } }, "required": [ "id", + "userId", "title", "content", + "createdAt" + ] + }, + "MemoResponseRel": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "user": { + "$ref": "#/components/schemas/UserSchema" + }, + "userId": { + "type": "integer" + } + }, + "required": [ + "id", + "userId", + "title", + "content", + "createdAt", + "user" + ] + }, + "MemoSchema": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "user": { + "$ref": "#/components/schemas/UserSchema" + }, + "userId": { + "type": "integer" + } + }, + "required": [ + "id", + "userId", + "title", + "content", + "createdAt", + "updatedAt", + "user" + ] + }, + "MemoSnakeCase": { + "type": "object", + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "integer" + }, + "user_id": { + "type": "integer" + } + }, + "required": [ + "id", + "user_id", "created_at" ] }, @@ -2231,6 +2351,42 @@ "email" ] }, + "UserSchema": { + "type": "object", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time" + }, + "email": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "memos": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MemoSchema" + } + }, + "name": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "email", + "name", + "createdAt", + "updatedAt", + "memos" + ] + }, "UserSummary": { "type": "object", "properties": { From c8d8f4a08dca9bd556abe13951b5d5213c4e5035 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 4 Feb 2026 00:51:19 +0900 Subject: [PATCH 2/5] Impl schema --- crates/vespera_macro/src/schema_macro.rs | 5 ----- examples/axum-example/openapi.json | 4 ++-- openapi.json | 4 ++-- 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/crates/vespera_macro/src/schema_macro.rs b/crates/vespera_macro/src/schema_macro.rs index 44e0d11..45c3405 100644 --- a/crates/vespera_macro/src/schema_macro.rs +++ b/crates/vespera_macro/src/schema_macro.rs @@ -207,8 +207,6 @@ struct RelationFieldInfo { schema_path: TokenStream, /// Whether the relation is optional is_optional: bool, - /// Foreign key field name (for BelongsTo) - fk_field: Option, } /// Extract the "from" field name from a sea_orm belongs_to attribute. @@ -369,7 +367,6 @@ fn convert_relation_type_to_schema_with_info( relation_type: "HasOne".to_string(), schema_path: schema_path.clone(), is_optional, - fk_field, }; Some((converted, info)) } @@ -380,7 +377,6 @@ fn convert_relation_type_to_schema_with_info( relation_type: "HasMany".to_string(), schema_path: schema_path.clone(), is_optional: false, - fk_field: None, }; Some((converted, info)) } @@ -404,7 +400,6 @@ fn convert_relation_type_to_schema_with_info( relation_type: "BelongsTo".to_string(), schema_path: schema_path.clone(), is_optional, - fk_field, }; Some((converted, info)) } diff --git a/examples/axum-example/openapi.json b/examples/axum-example/openapi.json index 3e423b6..9f3f2b5 100644 --- a/examples/axum-example/openapi.json +++ b/examples/axum-example/openapi.json @@ -1014,7 +1014,7 @@ "/third": { "get": { "operationId": "third_root_endpoint", - "description": "/ Third app root endpoint", + "description": "Third app root endpoint", "responses": { "200": { "description": "Successful response", @@ -1035,7 +1035,7 @@ "tags": [ "third" ], - "description": "/ Third app hello endpoint", + "description": "Third app hello endpoint", "responses": { "200": { "description": "Successful response", diff --git a/openapi.json b/openapi.json index 3e423b6..9f3f2b5 100644 --- a/openapi.json +++ b/openapi.json @@ -1014,7 +1014,7 @@ "/third": { "get": { "operationId": "third_root_endpoint", - "description": "/ Third app root endpoint", + "description": "Third app root endpoint", "responses": { "200": { "description": "Successful response", @@ -1035,7 +1035,7 @@ "tags": [ "third" ], - "description": "/ Third app hello endpoint", + "description": "Third app hello endpoint", "responses": { "200": { "description": "Successful response", From d40219d2d7c056a5bd6d90b9f86a886925269651 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 4 Feb 2026 01:00:50 +0900 Subject: [PATCH 3/5] Add test --- crates/vespera_macro/src/schema_macro.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/crates/vespera_macro/src/schema_macro.rs b/crates/vespera_macro/src/schema_macro.rs index 45c3405..df0f9a9 100644 --- a/crates/vespera_macro/src/schema_macro.rs +++ b/crates/vespera_macro/src/schema_macro.rs @@ -1090,6 +1090,7 @@ impl Parse for SchemaTypeInput { } "rename_all" => { // rename_all = "camelCase" — serde rename_all strategy + // Validation is delegated to serde at compile time input.parse::()?; let rename_all_lit: LitStr = input.parse()?; rename_all = Some(rename_all_lit.value()); @@ -2890,4 +2891,25 @@ mod tests { let result = extract_type_name(&ty); assert!(result.is_err()); } + + // ========================================================================= + // Tests for rename_all parsing + // ========================================================================= + + #[test] + fn test_parse_schema_type_input_with_rename_all() { + let tokens = quote::quote!(NewType from User, rename_all = "snake_case"); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + assert_eq!(input.rename_all.as_deref(), Some("snake_case")); + } + + #[test] + fn test_parse_schema_type_input_rename_all_with_other_params() { + // rename_all should work alongside other parameters + let tokens = + quote::quote!(NewType from User, pick = ["id", "name"], rename_all = "snake_case"); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + assert_eq!(input.pick.unwrap(), vec!["id", "name"]); + assert_eq!(input.rename_all.as_deref(), Some("snake_case")); + } } From 32d8924a144f1bca8882cd0ef0a8051613034854 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 4 Feb 2026 02:09:08 +0900 Subject: [PATCH 4/5] Add orm schema --- Cargo.lock | 1 + crates/vespera/Cargo.toml | 1 + crates/vespera/src/lib.rs | 4 + crates/vespera_macro/src/parser/schema.rs | 38 +- crates/vespera_macro/src/schema_macro.rs | 978 ++++++++++++++++-- examples/axum-example/Cargo.toml | 2 +- .../models/comment.vespertide.json | 24 + examples/axum-example/openapi.json | 97 +- examples/axum-example/src/models/comment.rs | 30 + examples/axum-example/src/models/memo.rs | 4 +- examples/axum-example/src/models/mod.rs | 1 + examples/axum-example/src/models/user.rs | 4 +- examples/axum-example/src/routes/memos.rs | 26 +- .../snapshots/integration_test__openapi.snap | 117 ++- openapi.json | 97 +- 15 files changed, 1284 insertions(+), 140 deletions(-) create mode 100644 examples/axum-example/models/comment.vespertide.json create mode 100644 examples/axum-example/src/models/comment.rs diff --git a/Cargo.lock b/Cargo.lock index 083b597..aa82f54 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3077,6 +3077,7 @@ version = "0.1.27" dependencies = [ "axum", "axum-extra", + "chrono", "serde_json", "tower-layer", "tower-service", diff --git a/crates/vespera/Cargo.toml b/crates/vespera/Cargo.toml index 191cd3d..3ddfdfe 100644 --- a/crates/vespera/Cargo.toml +++ b/crates/vespera/Cargo.toml @@ -15,6 +15,7 @@ vespera_core = { workspace = true } vespera_macro = { workspace = true } axum = "0.8" axum-extra = { version = "0.12", optional = true } +chrono = { version = "0.4", features = ["serde"] } serde_json = "1" tower-layer = "0.3" tower-service = "0.3" diff --git a/crates/vespera/src/lib.rs b/crates/vespera/src/lib.rs index c77a191..b6463d9 100644 --- a/crates/vespera/src/lib.rs +++ b/crates/vespera/src/lib.rs @@ -25,6 +25,10 @@ pub use vespera_macro::{Schema, export_app, route, schema, schema_type, vespera} // Re-export serde_json for merge feature (runtime spec merging) pub use serde_json; +// Re-export chrono for schema_type! datetime conversion +// This allows generated types to use chrono::DateTime without users adding chrono dependency +pub use chrono; + // Re-export axum for convenience pub mod axum { pub use axum::*; diff --git a/crates/vespera_macro/src/parser/schema.rs b/crates/vespera_macro/src/parser/schema.rs index 15cd023..867d863 100644 --- a/crates/vespera_macro/src/parser/schema.rs +++ b/crates/vespera_macro/src/parser/schema.rs @@ -916,7 +916,11 @@ pub(super) fn parse_type_to_schema_ref_with_schemas( // Box -> T's schema (Box is just heap allocation, transparent for schema) "Box" => { if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() { - return parse_type_to_schema_ref(inner_ty, known_schemas, struct_definitions); + return parse_type_to_schema_ref( + inner_ty, + known_schemas, + struct_definitions, + ); } } "Vec" | "Option" => { @@ -951,27 +955,29 @@ pub(super) fn parse_type_to_schema_ref_with_schemas( // SeaORM relation types: convert Entity to Schema reference "HasOne" => { // HasOne -> nullable reference to corresponding Schema - if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() { - if let Some(schema_name) = extract_schema_name_from_entity(inner_ty) { - return SchemaRef::Inline(Box::new(Schema { - ref_path: Some(format!("#/components/schemas/{}", schema_name)), - schema_type: None, - nullable: Some(true), - ..Schema::new(SchemaType::Object) - })); - } + if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() + && let Some(schema_name) = extract_schema_name_from_entity(inner_ty) + { + return SchemaRef::Inline(Box::new(Schema { + ref_path: Some(format!("#/components/schemas/{}", schema_name)), + schema_type: None, + nullable: Some(true), + ..Schema::new(SchemaType::Object) + })); } // Fallback: generic object return SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object))); } "HasMany" => { // HasMany -> array of references to corresponding Schema - if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() { - if let Some(schema_name) = extract_schema_name_from_entity(inner_ty) { - let inner_ref = - SchemaRef::Ref(Reference::new(format!("#/components/schemas/{}", schema_name))); - return SchemaRef::Inline(Box::new(Schema::array(inner_ref))); - } + if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() + && let Some(schema_name) = extract_schema_name_from_entity(inner_ty) + { + let inner_ref = SchemaRef::Ref(Reference::new(format!( + "#/components/schemas/{}", + schema_name + ))); + return SchemaRef::Inline(Box::new(Schema::array(inner_ref))); } // Fallback: array of generic objects return SchemaRef::Inline(Box::new(Schema::array(SchemaRef::Inline( diff --git a/crates/vespera_macro/src/schema_macro.rs b/crates/vespera_macro/src/schema_macro.rs index df0f9a9..693e24b 100644 --- a/crates/vespera_macro/src/schema_macro.rs +++ b/crates/vespera_macro/src/schema_macro.rs @@ -9,7 +9,7 @@ use quote::quote; use std::collections::HashSet; use std::path::Path; use syn::punctuated::Punctuated; -use syn::{bracketed, parenthesized, parse::Parse, parse::ParseStream, Ident, LitStr, Token, Type}; +use syn::{Ident, LitStr, Token, Type, bracketed, parenthesized, parse::Parse, parse::ParseStream}; use crate::metadata::StructMetadata; use crate::parser::{ @@ -196,6 +196,69 @@ fn is_seaorm_model(struct_item: &syn::ItemStruct) -> bool { false } +/// Convert SeaORM datetime types to chrono equivalents. +/// +/// This allows generated schemas to use standard chrono types instead of +/// requiring `use sea_orm::entity::prelude::DateTimeWithTimeZone`. +/// +/// Conversions: +/// - `DateTimeWithTimeZone` → `chrono::DateTime` +/// - `DateTimeUtc` → `chrono::DateTime` +/// - `DateTimeLocal` → `chrono::DateTime` +/// - `DateTime` (SeaORM) → `chrono::NaiveDateTime` +/// - `Date` (SeaORM) → `chrono::NaiveDate` +/// - `Time` (SeaORM) → `chrono::NaiveTime` +/// +/// Returns the original type as TokenStream if not a SeaORM datetime type. +fn convert_seaorm_type_to_chrono(ty: &Type) -> TokenStream { + let type_path = match ty { + Type::Path(tp) => tp, + _ => return quote! { #ty }, + }; + + let segment = match type_path.path.segments.last() { + Some(s) => s, + None => return quote! { #ty }, + }; + + let ident_str = segment.ident.to_string(); + + match ident_str.as_str() { + // Use vespera::chrono to avoid requiring users to add chrono dependency + "DateTimeWithTimeZone" => { + quote! { vespera::chrono::DateTime } + } + "DateTimeUtc" => quote! { vespera::chrono::DateTime }, + "DateTimeLocal" => quote! { vespera::chrono::DateTime }, + // Note: NaiveDateTime, NaiveDate, NaiveTime are already chrono types + // so they don't need conversion. Only convert SeaORM-specific aliases. + _ => quote! { #ty }, + } +} + +/// Convert a type to chrono equivalent, handling Option wrapper. +/// +/// If the type is `Option`, converts to `Option`. +/// If the type is just `SeaOrmType`, converts to `ChronoType`. +fn convert_type_with_chrono(ty: &Type) -> TokenStream { + // Check if it's Option + if let Type::Path(type_path) = ty + && let Some(segment) = type_path.path.segments.first() + && segment.ident == "Option" + { + // Extract the inner type from Option + if let syn::PathArguments::AngleBracketed(args) = &segment.arguments + && let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() + { + let converted_inner = convert_seaorm_type_to_chrono(inner_ty); + return quote! { Option<#converted_inner> }; + } + } + + // Not Option, convert directly + convert_seaorm_type_to_chrono(ty) +} + /// Relation field info for generating from_model code #[derive(Clone)] struct RelationFieldInfo { @@ -207,6 +270,9 @@ struct RelationFieldInfo { schema_path: TokenStream, /// Whether the relation is optional is_optional: bool, + /// If Some, this relation has circular refs and uses an inline type + /// Contains: (inline_type_name, circular_fields_to_exclude) + inline_type_info: Option<(syn::Ident, Vec)>, } /// Extract the "from" field name from a sea_orm belongs_to attribute. @@ -216,12 +282,11 @@ fn extract_belongs_to_from_field(attrs: &[syn::Attribute]) -> Option { if attr.path().is_ident("sea_orm") { let mut from_field = None; let _ = attr.parse_nested_meta(|meta| { - if meta.path.is_ident("from") { - if let Ok(value) = meta.value() { - if let Ok(lit) = value.parse::() { - from_field = Some(lit.value()); - } - } + if meta.path.is_ident("from") + && let Ok(value) = meta.value() + && let Ok(lit) = value.parse::() + { + from_field = Some(lit.value()); } Ok(()) }); @@ -237,10 +302,10 @@ fn extract_belongs_to_from_field(attrs: &[syn::Attribute]) -> Option { fn is_field_optional_in_struct(struct_item: &syn::ItemStruct, field_name: &str) -> bool { if let syn::Fields::Named(fields_named) = &struct_item.fields { for field in &fields_named.named { - if let Some(ident) = &field.ident { - if ident == field_name { - return is_option_type(&field.ty); - } + if let Some(ident) = &field.ident + && ident == field_name + { + return is_option_type(&field.ty); } } } @@ -367,6 +432,7 @@ fn convert_relation_type_to_schema_with_info( relation_type: "HasOne".to_string(), schema_path: schema_path.clone(), is_optional, + inline_type_info: None, // Will be populated later if circular }; Some((converted, info)) } @@ -377,6 +443,7 @@ fn convert_relation_type_to_schema_with_info( relation_type: "HasMany".to_string(), schema_path: schema_path.clone(), is_optional: false, + inline_type_info: None, // Will be populated later if circular }; Some((converted, info)) } @@ -400,6 +467,7 @@ fn convert_relation_type_to_schema_with_info( relation_type: "BelongsTo".to_string(), schema_path: schema_path.clone(), is_optional, + inline_type_info: None, // Will be populated later if circular }; Some((converted, info)) } @@ -766,7 +834,24 @@ fn schema_to_tokens(schema: &Schema) -> TokenStream { /// 1. Parse the path (e.g., `models::memo::Model` or `crate::models::memo::Model`) /// 2. Convert to file path (e.g., `src/models/memo.rs`) /// 3. Read and parse the file to find the struct definition -fn find_struct_from_path(ty: &Type) -> Option { +/// +/// For simple names (e.g., just `Model` without module path), it will scan all `.rs` +/// files in `src/` to find the struct. This supports same-file usage like: +/// ```ignore +/// pub struct Model { ... } +/// vespera::schema_type!(Schema from Model, name = "UserSchema"); +/// ``` +/// +/// The `schema_name_hint` is used to disambiguate when multiple structs with the same +/// name exist. For example, with `name = "UserSchema"`, it will prefer `user.rs`. +/// +/// Returns `(StructMetadata, Vec)` where the Vec is the module path. +/// For qualified paths, this is extracted from the type itself. +/// For simple names, it's inferred from the file location. +fn find_struct_from_path( + ty: &Type, + schema_name_hint: Option<&str>, +) -> Option<(StructMetadata, Vec)> { // Get CARGO_MANIFEST_DIR to locate src folder let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok()?; let src_dir = Path::new(&manifest_dir).join("src"); @@ -800,10 +885,15 @@ fn find_struct_from_path(ty: &Type) -> Option { .map(|s| s.as_str()) .collect(); + // If no module path (simple name like `Model`), scan all files with schema_name hint if module_segments.is_empty() { - return None; + return find_struct_by_name_in_all_files(&src_dir, &struct_name, schema_name_hint); } + // For qualified paths, the module path is extracted from the type itself + // e.g., crate::models::memo::Model → ["crate", "models", "memo"] + let type_module_path: Vec = segments[..segments.len() - 1].to_vec(); + // Try different file path patterns let file_paths = vec![ src_dir.join(format!("{}.rs", module_segments.join("/"))), @@ -822,9 +912,12 @@ fn find_struct_from_path(ty: &Type) -> Option { for item in &file_ast.items { match item { syn::Item::Struct(struct_item) if struct_item.ident == struct_name => { - return Some(StructMetadata::new_model( - struct_name.clone(), - quote::quote!(#struct_item).to_string(), + return Some(( + StructMetadata::new_model( + struct_name.clone(), + quote::quote!(#struct_item).to_string(), + ), + type_module_path, )); } _ => continue, @@ -835,6 +928,153 @@ fn find_struct_from_path(ty: &Type) -> Option { None } +/// Find a struct by name by scanning all `.rs` files in the src directory. +/// +/// This is used as a fallback when the type path doesn't include module information +/// (e.g., just `Model` instead of `crate::models::user::Model`). +/// +/// Resolution strategy: +/// 1. If exactly one struct with the name exists → use it +/// 2. If multiple exist and schema_name_hint is provided (e.g., "UserSchema"): +/// → Prefer file whose name contains the hint prefix (e.g., "user.rs" for "UserSchema") +/// 3. Otherwise → return None (ambiguous) +/// +/// The `schema_name_hint` is the custom schema name (e.g., "UserSchema", "MemoSchema") +/// which often contains a hint about the module name. +/// +/// Returns `(StructMetadata, Vec)` where the Vec is the inferred module path +/// from the file location (e.g., `["crate", "models", "user"]`). +fn find_struct_by_name_in_all_files( + src_dir: &Path, + struct_name: &str, + schema_name_hint: Option<&str>, +) -> Option<(StructMetadata, Vec)> { + // Collect all .rs files recursively + let mut rs_files = Vec::new(); + collect_rs_files_recursive(src_dir, &mut rs_files); + + // Store: (file_path, struct_metadata) + let mut found_structs: Vec<(std::path::PathBuf, StructMetadata)> = Vec::new(); + + for file_path in rs_files { + let content = match std::fs::read_to_string(&file_path) { + Ok(c) => c, + Err(_) => continue, + }; + + let file_ast = match syn::parse_file(&content) { + Ok(ast) => ast, + Err(_) => continue, + }; + + // Look for the struct in the file + for item in &file_ast.items { + if let syn::Item::Struct(struct_item) = item + && struct_item.ident == struct_name + { + found_structs.push(( + file_path.clone(), + StructMetadata::new_model( + struct_name.to_string(), + quote::quote!(#struct_item).to_string(), + ), + )); + } + } + } + + match found_structs.len() { + 0 => None, + 1 => { + let (path, metadata) = found_structs.remove(0); + let module_path = file_path_to_module_path(&path, src_dir); + Some((metadata, module_path)) + } + _ => { + // Multiple structs with same name - try to disambiguate using schema_name_hint + if let Some(hint) = schema_name_hint { + // Extract prefix from schema name (e.g., "UserSchema" -> "user", "MemoSchema" -> "memo") + let hint_lower = hint.to_lowercase(); + let prefix = hint_lower + .strip_suffix("schema") + .or_else(|| hint_lower.strip_suffix("response")) + .or_else(|| hint_lower.strip_suffix("request")) + .unwrap_or(&hint_lower); + + // Find files whose name contains the prefix + let matching: Vec<_> = found_structs + .into_iter() + .filter(|(path, _)| { + path.file_stem() + .and_then(|s| s.to_str()) + .is_some_and(|name| name.to_lowercase().contains(prefix)) + }) + .collect(); + + if matching.len() == 1 { + let (path, metadata) = matching.into_iter().next().unwrap(); + let module_path = file_path_to_module_path(&path, src_dir); + return Some((metadata, module_path)); + } + } + + // Still ambiguous + None + } + } +} + +/// Recursively collect all `.rs` files in a directory. +fn collect_rs_files_recursive(dir: &Path, files: &mut Vec) { + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return, + }; + + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_rs_files_recursive(&path, files); + } else if path.extension().is_some_and(|ext| ext == "rs") { + files.push(path); + } + } +} + +/// Derive module path from a file path relative to src directory. +/// +/// Examples: +/// - `src/models/user.rs` → `["crate", "models", "user"]` +/// - `src/models/user/mod.rs` → `["crate", "models", "user"]` +/// - `src/lib.rs` → `["crate"]` +fn file_path_to_module_path(file_path: &Path, src_dir: &Path) -> Vec { + let relative = match file_path.strip_prefix(src_dir) { + Ok(r) => r, + Err(_) => return vec!["crate".to_string()], + }; + + let mut segments = vec!["crate".to_string()]; + + for component in relative.components() { + if let std::path::Component::Normal(os_str) = component + && let Some(s) = os_str.to_str() + { + // Handle .rs extension + if let Some(name) = s.strip_suffix(".rs") { + // Skip mod.rs and lib.rs - they don't add a segment + if name != "mod" && name != "lib" { + segments.push(name.to_string()); + } + } else { + // Directory name + segments.push(s.to_string()); + } + } + } + + segments +} + /// Find struct definition from a schema path string (e.g., "crate::models::user::Schema"). /// /// Similar to `find_struct_from_path` but takes a string path instead of syn::Type. @@ -896,6 +1136,278 @@ fn find_struct_from_schema_path(path_str: &str) -> Option { None } +/// Find the Model definition from a Schema path. +/// Converts "crate::models::user::Schema" -> finds Model in src/models/user.rs +fn find_model_from_schema_path(schema_path_str: &str) -> Option { + // Get CARGO_MANIFEST_DIR to locate src folder + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok()?; + let src_dir = Path::new(&manifest_dir).join("src"); + + // Parse the path string and convert Schema path to module path + // e.g., "crate :: models :: user :: Schema" -> ["crate", "models", "user"] + let segments: Vec<&str> = schema_path_str + .split("::") + .map(|s| s.trim()) + .filter(|s| !s.is_empty() && *s != "Schema") + .collect(); + + if segments.is_empty() { + return None; + } + + // Build possible file paths from the module path + let module_segments: Vec<&str> = segments + .iter() + .filter(|s| **s != "crate" && **s != "self" && **s != "super") + .copied() + .collect(); + + if module_segments.is_empty() { + return None; + } + + // Try different file path patterns + let file_paths = vec![ + src_dir.join(format!("{}.rs", module_segments.join("/"))), + src_dir.join(format!("{}/mod.rs", module_segments.join("/"))), + ]; + + for file_path in file_paths { + if !file_path.exists() { + continue; + } + + let content = std::fs::read_to_string(&file_path).ok()?; + let file_ast = syn::parse_file(&content).ok()?; + + // Look for Model struct in the file + for item in &file_ast.items { + if let syn::Item::Struct(struct_item) = item + && struct_item.ident == "Model" + { + return Some(StructMetadata::new_model( + "Model".to_string(), + quote::quote!(#struct_item).to_string(), + )); + } + } + } + + None +} + +/// Information about an inline relation type to generate +struct InlineRelationType { + /// Name of the inline type (e.g., MemoResponseRel_User) + type_name: syn::Ident, + /// Fields to include (excluding circular references) + fields: Vec, + /// The effective rename_all strategy + rename_all: String, +} + +/// A field in an inline relation type +struct InlineField { + name: syn::Ident, + ty: TokenStream, + attrs: Vec, +} + +/// Generate inline relation type definition for circular references. +/// +/// When `MemoSchema.user` would reference `UserSchema` which has `memos: Vec`, +/// we instead generate an inline type `MemoSchema_User` that excludes the `memos` field. +/// +/// The `schema_name_override` parameter allows using a custom schema name (e.g., "MemoSchema") +/// instead of the Rust struct name (e.g., "Schema") for the inline type name. +fn generate_inline_relation_type( + parent_type_name: &syn::Ident, + rel_info: &RelationFieldInfo, + source_module_path: &[String], + schema_name_override: Option<&str>, +) -> Option { + // Find the target model definition + let schema_path_str = rel_info.schema_path.to_string(); + let model_metadata = find_model_from_schema_path(&schema_path_str)?; + let model_def = &model_metadata.definition; + + // Parse the model struct + let parsed_model: syn::ItemStruct = syn::parse_str(model_def).ok()?; + + // Detect circular fields + let circular_fields = detect_circular_fields("", source_module_path, model_def); + + // If no circular fields, no need for inline type + if circular_fields.is_empty() { + return None; + } + + // Get rename_all from model (or default to camelCase) + let rename_all = + extract_rename_all(&parsed_model.attrs).unwrap_or_else(|| "camelCase".to_string()); + + // Generate inline type name: {SchemaName}_{Field} + // Use custom schema name if provided, otherwise use the Rust struct name + let parent_name = match schema_name_override { + Some(name) => name.to_string(), + None => parent_type_name.to_string(), + }; + let field_name_pascal = capitalize_first(&rel_info.field_name.to_string()); + let inline_type_name = syn::Ident::new( + &format!("{}_{}", parent_name, field_name_pascal), + proc_macro2::Span::call_site(), + ); + + // Collect fields, excluding circular ones and relation types + let mut fields = Vec::new(); + if let syn::Fields::Named(fields_named) = &parsed_model.fields { + for field in &fields_named.named { + let field_ident = field.ident.as_ref()?; + let field_name_str = field_ident.to_string(); + + // Skip circular fields + if circular_fields.contains(&field_name_str) { + continue; + } + + // Skip relation types (HasOne, HasMany, BelongsTo) + if is_seaorm_relation_type(&field.ty) { + continue; + } + + // Skip fields with serde(skip) + if extract_skip(&field.attrs) { + continue; + } + + // Keep only serde attributes + let serde_attrs: Vec = field + .attrs + .iter() + .filter(|attr| attr.path().is_ident("serde")) + .cloned() + .collect(); + + let field_ty = &field.ty; + fields.push(InlineField { + name: field_ident.clone(), + ty: quote::quote!(#field_ty), + attrs: serde_attrs, + }); + } + } + + Some(InlineRelationType { + type_name: inline_type_name, + fields, + rename_all, + }) +} + +/// Generate inline relation type for HasMany with ALL relations stripped. +/// +/// When a HasMany relation is explicitly picked, the nested items should have +/// NO relation fields at all (not even FK relations). This prevents infinite +/// nesting and keeps the schema simple. +/// +/// Example: If UserSchema picks "memos", each memo in the list will have +/// id, user_id, title, content, etc. but NO user or comments relations. +fn generate_inline_relation_type_no_relations( + parent_type_name: &syn::Ident, + rel_info: &RelationFieldInfo, + schema_name_override: Option<&str>, +) -> Option { + // Find the target model definition + let schema_path_str = rel_info.schema_path.to_string(); + let model_metadata = find_model_from_schema_path(&schema_path_str)?; + let model_def = &model_metadata.definition; + + // Parse the model struct + let parsed_model: syn::ItemStruct = syn::parse_str(model_def).ok()?; + + // Get rename_all from model (or default to camelCase) + let rename_all = + extract_rename_all(&parsed_model.attrs).unwrap_or_else(|| "camelCase".to_string()); + + // Generate inline type name: {SchemaName}_{Field} + let parent_name = match schema_name_override { + Some(name) => name.to_string(), + None => parent_type_name.to_string(), + }; + let field_name_pascal = capitalize_first(&rel_info.field_name.to_string()); + let inline_type_name = syn::Ident::new( + &format!("{}_{}", parent_name, field_name_pascal), + proc_macro2::Span::call_site(), + ); + + // Collect fields, excluding ALL relation types + let mut fields = Vec::new(); + if let syn::Fields::Named(fields_named) = &parsed_model.fields { + for field in &fields_named.named { + let field_ident = field.ident.as_ref()?; + + // Skip ALL relation types (HasOne, HasMany, BelongsTo) + if is_seaorm_relation_type(&field.ty) { + continue; + } + + // Skip fields with serde(skip) + if extract_skip(&field.attrs) { + continue; + } + + // Keep only serde attributes + let serde_attrs: Vec = field + .attrs + .iter() + .filter(|attr| attr.path().is_ident("serde")) + .cloned() + .collect(); + + let field_ty = &field.ty; + fields.push(InlineField { + name: field_ident.clone(), + ty: quote::quote!(#field_ty), + attrs: serde_attrs, + }); + } + } + + Some(InlineRelationType { + type_name: inline_type_name, + fields, + rename_all, + }) +} + +/// Generate the struct definition TokenStream for an inline relation type +fn generate_inline_type_definition(inline_type: &InlineRelationType) -> TokenStream { + let type_name = &inline_type.type_name; + let rename_all = &inline_type.rename_all; + + let field_tokens: Vec = inline_type + .fields + .iter() + .map(|f| { + let name = &f.name; + let ty = &f.ty; + let attrs = &f.attrs; + quote! { + #(#attrs)* + pub #name: #ty + } + }) + .collect(); + + quote! { + #[derive(Clone, serde::Serialize, serde::Deserialize, vespera::Schema)] + #[serde(rename_all = #rename_all)] + pub struct #type_name { + #(#field_tokens),* + } + } +} + /// Input for the schema_type! macro /// /// Syntax: `schema_type!(NewTypeName from SourceType, pick = ["field1", "field2"])` @@ -1156,7 +1668,10 @@ fn extract_module_path(ty: &Type) -> Vec { /// Detect circular reference fields in a related schema. /// /// When generating `MemoSchema.user`, we need to check if `UserSchema` has any fields -/// that reference back to `MemoSchema` (e.g., `memos: Vec`). +/// that reference back to `MemoSchema` via BelongsTo/HasOne (FK-based relations). +/// +/// HasMany relations are NOT considered circular because they are excluded by default +/// from generated schemas. /// /// Returns a list of field names that would create circular references. fn detect_circular_fields( @@ -1189,17 +1704,24 @@ fn detect_circular_fields( // Remove all whitespace to make pattern matching reliable let ty_str_normalized = ty_str.replace(' ', ""); - // Check for patterns like: - // - Vec or Vec - // - Box or Box - // - Option> - // - HasMany + // SKIP HasMany relations - they are excluded by default from schemas, + // so they don't create actual circular references in the output + if ty_str_normalized.contains("HasMany<") { + continue; + } + + // Check for BelongsTo/HasOne patterns that reference the source: // - HasOne // - BelongsTo - let is_circular = ty_str_normalized.contains(&format!("{}::Schema", source_module)) - || ty_str_normalized.contains(&format!("{}::Entity", source_module)) - || ty_str_normalized - .contains(&format!("{}Schema", capitalize_first(source_module))); + // - Box (already converted) + // - Option> + let is_circular = (ty_str_normalized.contains("HasOne<") + || ty_str_normalized.contains("BelongsTo<") + || ty_str_normalized.contains("Box<")) + && (ty_str_normalized.contains(&format!("{}::Schema", source_module)) + || ty_str_normalized.contains(&format!("{}::Entity", source_module)) + || ty_str_normalized + .contains(&format!("{}Schema", capitalize_first(source_module)))); if is_circular { circular_fields.push(field_name); @@ -1210,6 +1732,33 @@ fn detect_circular_fields( circular_fields } +/// Check if a Model has any BelongsTo or HasOne relations (FK-based relations). +/// +/// This is used to determine if the target schema has `from_model()` method +/// (async, with DB) or simple `From` impl (sync, no DB). +/// +/// - Schemas with FK relations → have `from_model()`, need async call +/// - Schemas without FK relations → have `From`, can use sync conversion +fn has_fk_relations(model_def: &str) -> bool { + let Ok(parsed) = syn::parse_str::(model_def) else { + return false; + }; + + if let syn::Fields::Named(fields_named) = &parsed.fields { + for field in &fields_named.named { + let field_ty = &field.ty; + let ty_str = quote::quote!(#field_ty).to_string().replace(' ', ""); + + // Check for BelongsTo or HasOne patterns + if ty_str.contains("HasOne<") || ty_str.contains("BelongsTo<") { + return true; + } + } + } + + false +} + /// Capitalize the first letter of a string. fn capitalize_first(s: &str) -> String { let mut chars = s.chars(); @@ -1287,6 +1836,76 @@ fn generate_inline_struct_construction( } } +/// Generate inline type construction for from_model. +/// +/// When we have an inline type (e.g., `MemoResponseRel_User`), this function generates +/// the construction code that only includes the fields present in the inline type. +/// +/// ```ignore +/// MemoResponseRel_User { +/// id: r.id, +/// name: r.name, +/// email: r.email, +/// // memos field is NOT included - it was excluded from inline type +/// } +/// ``` +fn generate_inline_type_construction( + inline_type_name: &syn::Ident, + included_fields: &[String], + related_model_def: &str, + var_name: &str, +) -> TokenStream { + // Parse the related model definition + let Ok(parsed) = syn::parse_str::(related_model_def) else { + // Fallback to Default if parsing fails + return quote! { Default::default() }; + }; + + let var_ident = syn::Ident::new(var_name, proc_macro2::Span::call_site()); + + // Get the named fields + let fields_named = match &parsed.fields { + syn::Fields::Named(f) => f, + _ => { + return quote! { Default::default() }; + } + }; + + let field_assignments: Vec = fields_named + .named + .iter() + .filter_map(|field| { + let field_ident = field.ident.as_ref()?; + let field_name = field_ident.to_string(); + + // Skip fields marked with serde(skip) + if extract_skip(&field.attrs) { + return None; + } + + // Skip relation fields (they are not in the inline type) + if is_seaorm_relation_type(&field.ty) { + return None; + } + + // Only include fields that are in the inline type's field list + if included_fields.contains(&field_name) { + // Regular field - copy from model + Some(quote! { #field_ident: #var_ident.#field_ident }) + } else { + // This field was excluded (circular reference or otherwise) + None + } + }) + .collect(); + + quote! { + #inline_type_name { + #(#field_assignments),* + } + } +} + /// Check if a circular relation field in the related schema is required (Box) or optional (Option>). /// /// Returns true if the circular relation is required and needs a parent stub. @@ -1300,7 +1919,7 @@ fn is_circular_relation_required(related_model_def: &str, circular_field_name: & let Some(field_ident) = &field.ident else { continue; }; - if field_ident.to_string() != circular_field_name { + if *field_ident != circular_field_name { continue; } @@ -1381,7 +2000,7 @@ fn generate_default_for_relation_field( /// // Load related entities /// let user = model.find_related(user::Entity).one(db).await?; /// let tags = model.find_related(tag::Entity).all(db).await?; -/// +/// /// Ok(Self { /// id: model.id, /// // Inline construction with circular field defaulted: @@ -1427,10 +2046,15 @@ fn generate_from_model_with_relations( // Check if we need a parent stub for HasMany relations with required circular back-refs // This is needed when: UserSchema.memos has MemoSchema which has required user: Box + // BUT: If the relation uses an inline type (which excludes circular fields), we don't need a parent stub let needs_parent_stub = relation_fields.iter().any(|rel| { if rel.relation_type != "HasMany" { return false; } + // If using inline type, circular fields are excluded, so no parent stub needed + if rel.inline_type_info.is_some() { + return false; + } let schema_path_str = rel.schema_path.to_string().replace(' ', ""); let model_path_str = schema_path_str.replace("::Schema", "::Model"); let related_model = find_struct_from_schema_path(&model_path_str); @@ -1490,25 +2114,25 @@ fn generate_from_model_with_relations( // Find the relation info for this field if let Some(rel) = relation_fields.iter().find(|r| &r.field_name == source_ident) { let schema_path = &rel.schema_path; - + // Try to find the related MODEL definition to check for circular refs // The schema_path is like "crate::models::user::Schema", but the actual // struct is "Model" in the same module. We need to look up the Model // to see if it has relations pointing back to us. let schema_path_str = schema_path.to_string().replace(' ', ""); - + // Convert schema path to model path: Schema -> Model let model_path_str = schema_path_str.replace("::Schema", "::Model"); - + // Try to find the related Model definition from file let related_model_from_file = find_struct_from_schema_path(&model_path_str); - + // Get the definition string let related_def_str = related_model_from_file .as_ref() .map(|s| s.definition.as_str()) .unwrap_or(""); - + // Check for circular references // The source module path tells us what module we're in (e.g., ["crate", "models", "memo"]) // We need to check if the related model has any relation fields pointing back to our module @@ -1517,19 +2141,22 @@ fn generate_from_model_with_relations( source_module_path, related_def_str, ); - + let has_circular = !circular_fields.is_empty(); - - match rel.relation_type.as_str() { - "HasOne" | "BelongsTo" => { - if has_circular { - // Use inline construction to break circular ref - let inline_construct = generate_inline_struct_construction( - schema_path, - related_def_str, - &circular_fields, - "r", - ); + + // Check if we have inline type info - if so, use the inline type + // instead of the original schema path + if let Some((ref inline_type_name, ref included_fields)) = rel.inline_type_info { + // Use inline type construction + let inline_construct = generate_inline_type_construction( + inline_type_name, + included_fields, + related_def_str, + "r", + ); + + match rel.relation_type.as_str() { + "HasOne" | "BelongsTo" => { if rel.is_optional { quote! { #new_ident: #source_ident.map(|r| Box::new(#inline_construct)) @@ -1544,42 +2171,120 @@ fn generate_from_model_with_relations( }) } } - } else { - // No circular ref - use From::from() - if rel.is_optional { - quote! { - #new_ident: #source_ident.map(|r| Box::new(<#schema_path as From<_>>::from(r))) + } + "HasMany" => { + quote! { + #new_ident: #source_ident.into_iter().map(|r| #inline_construct).collect() + } + } + _ => quote! { #new_ident: Default::default() }, + } + } else { + // No inline type - use original behavior + match rel.relation_type.as_str() { + "HasOne" | "BelongsTo" => { + if has_circular { + // Use inline construction to break circular ref + let inline_construct = generate_inline_struct_construction( + schema_path, + related_def_str, + &circular_fields, + "r", + ); + if rel.is_optional { + quote! { + #new_ident: #source_ident.map(|r| Box::new(#inline_construct)) + } + } else { + quote! { + #new_ident: Box::new({ + let r = #source_ident.ok_or_else(|| sea_orm::DbErr::RecordNotFound( + format!("Required relation '{}' not found", stringify!(#source_ident)) + ))?; + #inline_construct + }) + } } } else { - quote! { - #new_ident: Box::new(<#schema_path as From<_>>::from( - #source_ident.ok_or_else(|| sea_orm::DbErr::RecordNotFound( - format!("Required relation '{}' not found", stringify!(#source_ident)) - ))? - )) + // No circular ref - check if target schema has FK relations + let target_has_fk = has_fk_relations(related_def_str); + + if target_has_fk { + // Target schema has FK relations → use async from_model() + if rel.is_optional { + quote! { + #new_ident: match #source_ident { + Some(r) => Some(Box::new(#schema_path::from_model(r, db).await?)), + None => None, + } + } + } else { + quote! { + #new_ident: Box::new(#schema_path::from_model( + #source_ident.ok_or_else(|| sea_orm::DbErr::RecordNotFound( + format!("Required relation '{}' not found", stringify!(#source_ident)) + ))?, + db, + ).await?) + } + } + } else { + // Target schema has no FK relations → use sync From::from() + if rel.is_optional { + quote! { + #new_ident: #source_ident.map(|r| Box::new(<#schema_path as From<_>>::from(r))) + } + } else { + quote! { + #new_ident: Box::new(<#schema_path as From<_>>::from( + #source_ident.ok_or_else(|| sea_orm::DbErr::RecordNotFound( + format!("Required relation '{}' not found", stringify!(#source_ident)) + ))? + )) + } + } } } } - } - "HasMany" => { - if has_circular { - // Use inline construction to break circular ref - let inline_construct = generate_inline_struct_construction( - schema_path, - related_def_str, - &circular_fields, - "r", - ); - quote! { - #new_ident: #source_ident.into_iter().map(|r| #inline_construct).collect() - } - } else { - quote! { - #new_ident: #source_ident.into_iter().map(|r| <#schema_path as From<_>>::from(r)).collect() + "HasMany" => { + // HasMany is excluded by default, so this branch is only hit + // when explicitly picked. Use inline construction (no relations). + if has_circular { + // Use inline construction to break circular ref + let inline_construct = generate_inline_struct_construction( + schema_path, + related_def_str, + &circular_fields, + "r", + ); + quote! { + #new_ident: #source_ident.into_iter().map(|r| #inline_construct).collect() + } + } else { + // No circular ref - check if target schema has FK relations + let target_has_fk = has_fk_relations(related_def_str); + + if target_has_fk { + // Target has FK relations but HasMany doesn't load nested data anyway, + // so we use inline construction (flat fields only) + let inline_construct = generate_inline_struct_construction( + schema_path, + related_def_str, + &[], // no circular fields to exclude + "r", + ); + quote! { + #new_ident: #source_ident.into_iter().map(|r| #inline_construct).collect() + } + } else { + quote! { + #new_ident: #source_ident.into_iter().map(|r| <#schema_path as From<_>>::from(r)).collect() + } + } } } + _ => quote! { #new_ident: Default::default() }, } - _ => quote! { #new_ident: Default::default() }, } } else { quote! { #new_ident: Default::default() } @@ -1670,16 +2375,24 @@ pub fn generate_schema_type_code( let source_type_name = extract_type_name(&input.source_type)?; // Extract the module path for resolving relative paths in relation types - let source_module_path = extract_module_path(&input.source_type); + // This may be empty for simple names like `Model` - will be overridden below if found from file + let mut source_module_path = extract_module_path(&input.source_type); // Find struct definition - lookup order depends on whether path is qualified // For qualified paths (crate::models::memo::Model), try file lookup FIRST // to avoid name collisions when multiple modules have same struct name (e.g., Model) let struct_def_owned: StructMetadata; + let schema_name_hint = input.schema_name.as_deref(); let struct_def = if is_qualified_path(&input.source_type) { // Qualified path: try file lookup first, then storage - if let Some(found) = find_struct_from_path(&input.source_type) { + if let Some((found, module_path)) = + find_struct_from_path(&input.source_type, schema_name_hint) + { struct_def_owned = found; + // Use the module path from the file lookup if the extracted one is empty + if source_module_path.is_empty() { + source_module_path = module_path; + } &struct_def_owned } else if let Some(found) = schema_storage.iter().find(|s| s.name == source_type_name) { found @@ -1695,11 +2408,16 @@ pub fn generate_schema_type_code( )); } } else { - // Simple name: try storage first (for same-file structs), then file lookup + // Simple name: try storage first (for same-file structs), then file lookup with schema name hint if let Some(found) = schema_storage.iter().find(|s| s.name == source_type_name) { found - } else if let Some(found) = find_struct_from_path(&input.source_type) { + } else if let Some((found, module_path)) = + find_struct_from_path(&input.source_type, schema_name_hint) + { struct_def_owned = found; + // For simple names, we MUST use the inferred module path from the file location + // This is crucial for resolving relative paths like `super::user::Entity` + source_module_path = module_path; &struct_def_owned } else { return Err(syn::Error::new_spanned( @@ -1707,7 +2425,8 @@ pub fn generate_schema_type_code( format!( "type `{}` not found. Either:\n\ 1. Use #[derive(Schema)] in the same file\n\ - 2. Use full module path like `crate::models::memo::Model` to reference a struct from another file", + 2. Use full module path like `crate::models::memo::Model` to reference a struct from another file\n\ + 3. If using `name = \"XxxSchema\"`, ensure the file name matches (e.g., xxx.rs)", source_type_name ), )); @@ -1869,6 +2588,8 @@ pub fn generate_schema_type_code( let mut field_mappings: Vec<(syn::Ident, syn::Ident, bool, bool)> = Vec::new(); // Track relation field info for from_model generation let mut relation_fields: Vec = Vec::new(); + // Track inline types that need to be generated for circular relations + let mut inline_type_definitions: Vec = Vec::new(); if let syn::Fields::Named(fields_named) = &parsed_struct.fields { for field in &fields_named.named { @@ -1901,22 +2622,96 @@ pub fn generate_schema_type_code( let (field_ty, relation_info): (Box, Option) = if is_relation { // Convert HasOne/HasMany/BelongsTo to Schema type - if let Some((converted, rel_info)) = convert_relation_type_to_schema_with_info( - original_ty, - &field.attrs, - &parsed_struct, - &source_module_path, - field.ident.clone().unwrap(), - ) { - (Box::new(converted), Some(rel_info)) + if let Some((converted, mut rel_info)) = + convert_relation_type_to_schema_with_info( + original_ty, + &field.attrs, + &parsed_struct, + &source_module_path, + field.ident.clone().unwrap(), + ) + { + // NEW RULE: HasMany (reverse references) are excluded by default + // They can only be included via explicit `pick` + if rel_info.relation_type == "HasMany" { + // HasMany is only included if explicitly picked + if !pick_set.contains(&rust_field_name) { + continue; + } + // When HasMany IS picked, generate inline type with ALL relations stripped + if let Some(inline_type) = generate_inline_relation_type_no_relations( + new_type_name, + &rel_info, + input.schema_name.as_deref(), + ) { + let inline_type_def = generate_inline_type_definition(&inline_type); + inline_type_definitions.push(inline_type_def); + + let inline_type_name = &inline_type.type_name; + let included_fields: Vec = inline_type + .fields + .iter() + .map(|f| f.name.to_string()) + .collect(); + + rel_info.inline_type_info = + Some((inline_type.type_name.clone(), included_fields)); + + let inline_field_ty = quote! { Vec<#inline_type_name> }; + (Box::new(inline_field_ty), Some(rel_info)) + } else { + continue; + } + } else { + // BelongsTo/HasOne: Include by default + // Check for circular references and potentially use inline type + if let Some(inline_type) = generate_inline_relation_type( + new_type_name, + &rel_info, + &source_module_path, + input.schema_name.as_deref(), + ) { + // Generate inline type definition + let inline_type_def = generate_inline_type_definition(&inline_type); + inline_type_definitions.push(inline_type_def); + + // Use inline type instead of direct schema reference + let inline_type_name = &inline_type.type_name; + let circular_fields: Vec = inline_type + .fields + .iter() + .map(|f| f.name.to_string()) + .collect(); + + // Store inline type info + rel_info.inline_type_info = + Some((inline_type.type_name.clone(), circular_fields)); + + // Generate field type using inline type + let inline_field_ty = if rel_info.is_optional { + quote! { Option> } + } else { + quote! { Box<#inline_type_name> } + }; + + (Box::new(inline_field_ty), Some(rel_info)) + } else { + // No circular refs, use original schema path + (Box::new(converted), Some(rel_info)) + } + } } else { // Fallback: skip if conversion fails continue; } - } else if should_wrap_option { - (Box::new(quote! { Option<#original_ty> }), None) } else { - (Box::new(quote! { #original_ty }), None) + // Convert SeaORM datetime types to chrono equivalents + let converted_ty = convert_type_with_chrono(original_ty); + if should_wrap_option { + (Box::new(quote! { Option<#converted_ty> }), None) + } else { + (Box::new(converted_ty), None) + } }; // Collect relation info @@ -2072,8 +2867,11 @@ pub fn generate_schema_type_code( quote! {} }; - // Generate the new struct + // Generate the new struct (with inline types for circular relations first) let generated_tokens = quote! { + // Inline types for circular relation references + #(#inline_type_definitions)* + #[derive(serde::Serialize, serde::Deserialize, #clone_derive #schema_derive)] #schema_name_attr #[serde(rename_all = #effective_rename_all)] diff --git a/examples/axum-example/Cargo.toml b/examples/axum-example/Cargo.toml index 0b6060a..2e5b79e 100644 --- a/examples/axum-example/Cargo.toml +++ b/examples/axum-example/Cargo.toml @@ -10,7 +10,7 @@ tokio = { version = "1", features = ["full"] } serde = { version = "1", features = ["derive"] } serde_json = "1" tower-http = { version = "0.6", features = ["cors"] } -sea-orm = { version = "^2.0.0-rc.29", features = ["sqlx-sqlite", "runtime-tokio-rustls", "macros"] } +sea-orm = { version = "^2.0.0-rc.30", features = ["sqlx-sqlite", "runtime-tokio-rustls", "macros"] } third = { path = "../third" } diff --git a/examples/axum-example/models/comment.vespertide.json b/examples/axum-example/models/comment.vespertide.json new file mode 100644 index 0000000..d6148a8 --- /dev/null +++ b/examples/axum-example/models/comment.vespertide.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://raw.githubusercontent.com/dev-five-git/vespertide/refs/heads/main/schemas/model.schema.json", + "name": "comment", + "columns": [ + { "name": "id", "type": "integer", "nullable": false, "primary_key": { "auto_increment": true } }, + { + "name": "user_id", + "type": "integer", + "nullable": false, + "foreign_key": { "ref_table": "user", "ref_columns": ["id"], "on_delete": "cascade" }, + "index": true + }, + { + "name": "memo_id", + "type": "integer", + "nullable": false, + "foreign_key": { "ref_table": "memo", "ref_columns": ["id"], "on_delete": "cascade" }, + "index": true + }, + { "name": "content", "type": "text", "nullable": false }, + { "name": "created_at", "type": "timestamptz", "nullable": false, "default": "NOW()" }, + { "name": "updated_at", "type": "timestamptz", "nullable": false, "default": "NOW()" } + ] +} diff --git a/examples/axum-example/openapi.json b/examples/axum-example/openapi.json index 9f3f2b5..1901474 100644 --- a/examples/axum-example/openapi.json +++ b/examples/axum-example/openapi.json @@ -1329,6 +1329,47 @@ }, "components": { "schemas": { + "CommentSchema": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "integer" + }, + "memo": { + "$ref": "#/components/schemas/MemoSchema" + }, + "memoId": { + "type": "integer" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "user": { + "$ref": "#/components/schemas/UserSchema" + }, + "userId": { + "type": "integer" + } + }, + "required": [ + "id", + "userId", + "memoId", + "content", + "createdAt", + "updatedAt", + "user", + "memo" + ] + }, "ComplexStructBody": { "type": "object", "properties": { @@ -1954,6 +1995,53 @@ "createdAt" ] }, + "MemoResponseComments": { + "type": "object", + "properties": { + "comments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MemoResponseComments_Comments" + } + } + }, + "required": [ + "comments" + ] + }, + "MemoResponseComments_Comments": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "integer" + }, + "memoId": { + "type": "integer" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "userId": { + "type": "integer" + } + }, + "required": [ + "id", + "userId", + "memoId", + "content", + "createdAt", + "updatedAt" + ] + }, "MemoResponseRel": { "type": "object", "properties": { @@ -2364,12 +2452,6 @@ "id": { "type": "integer" }, - "memos": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MemoSchema" - } - }, "name": { "type": "string" }, @@ -2383,8 +2465,7 @@ "email", "name", "createdAt", - "updatedAt", - "memos" + "updatedAt" ] }, "UserSummary": { diff --git a/examples/axum-example/src/models/comment.rs b/examples/axum-example/src/models/comment.rs new file mode 100644 index 0000000..63598a0 --- /dev/null +++ b/examples/axum-example/src/models/comment.rs @@ -0,0 +1,30 @@ +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "comment")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + #[sea_orm(indexed)] + pub user_id: i32, + #[sea_orm(indexed)] + pub memo_id: i32, + pub content: String, + #[sea_orm(default_value = "NOW()")] + pub created_at: DateTimeWithTimeZone, + #[sea_orm(default_value = "NOW()")] + pub updated_at: DateTimeWithTimeZone, + #[sea_orm(belongs_to, from = "user_id", to = "id")] + pub user: HasOne, + #[sea_orm(belongs_to, from = "memo_id", to = "id")] + pub memo: HasOne, +} + +// Schema WITH both user and memo relations +// This tests complex circular reference handling: +// - Comment.user -> User (which has memos and comments) +// - Comment.memo -> Memo (which has user and comments) +vespera::schema_type!(Schema from Model, name = "CommentSchema"); + +impl ActiveModelBehavior for ActiveModel {} diff --git a/examples/axum-example/src/models/memo.rs b/examples/axum-example/src/models/memo.rs index 1a06592..604e2ac 100644 --- a/examples/axum-example/src/models/memo.rs +++ b/examples/axum-example/src/models/memo.rs @@ -16,11 +16,13 @@ pub struct Model { pub updated_at: DateTimeWithTimeZone, #[sea_orm(belongs_to, from = "user_id", to = "id")] pub user: HasOne, + #[sea_orm(has_many)] + pub comments: HasMany, } // Schema WITH user relation - has async from_model(model, db) method // Circular refs auto-handled: when loading user, its memos field is set to vec![] -vespera::schema_type!(Schema from crate::models::memo::Model, name = "MemoSchema"); +vespera::schema_type!(Schema from Model, name = "MemoSchema"); // Index definitions (SeaORM uses Statement builders externally) // (unnamed) on [user_id] diff --git a/examples/axum-example/src/models/mod.rs b/examples/axum-example/src/models/mod.rs index d51fe8f..8116906 100644 --- a/examples/axum-example/src/models/mod.rs +++ b/examples/axum-example/src/models/mod.rs @@ -1,2 +1,3 @@ +pub mod comment; pub mod memo; pub mod user; diff --git a/examples/axum-example/src/models/user.rs b/examples/axum-example/src/models/user.rs index 47c8140..50589a5 100644 --- a/examples/axum-example/src/models/user.rs +++ b/examples/axum-example/src/models/user.rs @@ -15,12 +15,14 @@ pub struct Model { pub updated_at: DateTimeWithTimeZone, #[sea_orm(has_many)] pub memos: HasMany, + #[sea_orm(has_many)] + pub comments: HasMany, } // Schema WITH memos relation - circular refs are auto-handled // When embedded in MemoSchema.user, the memos field will be defaulted to vec![] // Custom OpenAPI name: "UserSchema" -vespera::schema_type!(Schema from crate::models::user::Model, name = "UserSchema"); +vespera::schema_type!(Schema from Model, name = "UserSchema"); // Index definitions (SeaORM uses Statement builders externally) // (unnamed) on [email] diff --git a/examples/axum-example/src/routes/memos.rs b/examples/axum-example/src/routes/memos.rs index d1cb564..f26e660 100644 --- a/examples/axum-example/src/routes/memos.rs +++ b/examples/axum-example/src/routes/memos.rs @@ -10,9 +10,12 @@ use std::sync::Arc; // Import types used by the source model that we want to include in generated structs -use sea_orm::{entity::prelude::DateTimeWithTimeZone}; +use sea_orm::entity::prelude::DateTimeWithTimeZone; use vespera::{ - axum::{Json, extract::{Path, State}}, + axum::{ + Json, + extract::{Path, State}, + }, schema_type, }; @@ -31,12 +34,14 @@ schema_type!(CreateMemoRequest from crate::models::memo::Model, pick = ["title", // NO From impl (because `add` is used - can't auto-populate added fields) schema_type!(UpdateMemoRequest from crate::models::memo::Model, pick = ["title", "content"], add = [("id": i32)]); -// Response type: all fields except updated_at and user relation -// Has From impl since we omit the relation field -schema_type!(MemoResponse from crate::models::memo::Model, omit = ["updated_at", "user"]); +// Response type: all fields except updated_at and relations +// Has From impl since we omit all relation fields +schema_type!(MemoResponse from crate::models::memo::Model, omit = ["updated_at", "user", "comments"]); schema_type!(MemoResponseRel from crate::models::memo::Model, omit = ["updated_at"]); +schema_type!(MemoResponseComments from crate::models::memo::Model, pick = ["comments"]); + // Test rename_all override: use snake_case instead of default camelCase schema_type!(MemoSnakeCase from crate::models::memo::Model, pick = ["id", "user_id", "created_at"], rename_all = "snake_case"); @@ -78,7 +83,10 @@ pub async fn get_memo(Path(id): Path) -> Json { } #[vespera::route(get, path = "/{id}/rel")] -pub async fn get_memo_rel(Path(id): Path, State(app_state): State>) -> Json { +pub async fn get_memo_rel( + Path(id): Path, + State(app_state): State>, +) -> Json { // In real app, this would be a DB query returning Model // schema_type! generates From for MemoResponse, so .into() works let model = crate::models::memo::Model { @@ -89,7 +97,11 @@ pub async fn get_memo_rel(Path(id): Path, State(app_state): State Date: Wed, 4 Feb 2026 02:30:03 +0900 Subject: [PATCH 5/5] Add vespera schema_type --- .../changepack_log_PQDHMsoTbCYQqEAfpwVEi.json | 1 + AGENTS.md | 48 +++++-- README.md | 80 ++++++++++- SKILL.md | 119 ++++++++++++++- crates/vespera_macro/src/schema_macro.rs | 135 ++++++++++++++++-- .../axum-example/models/memo.vespertide.json | 6 + examples/axum-example/openapi.json | 20 +++ examples/axum-example/src/lib.rs | 4 +- examples/axum-example/src/models/comment.rs | 7 +- examples/axum-example/src/models/memo.rs | 17 +++ examples/axum-example/src/models/user.rs | 4 +- examples/axum-example/src/routes/memos.rs | 2 + .../axum-example/tests/integration_test.rs | 11 +- .../snapshots/integration_test__openapi.snap | 20 +++ openapi.json | 20 +++ 15 files changed, 458 insertions(+), 36 deletions(-) create mode 100644 .changepacks/changepack_log_PQDHMsoTbCYQqEAfpwVEi.json diff --git a/.changepacks/changepack_log_PQDHMsoTbCYQqEAfpwVEi.json b/.changepacks/changepack_log_PQDHMsoTbCYQqEAfpwVEi.json new file mode 100644 index 0000000..2921c12 --- /dev/null +++ b/.changepacks/changepack_log_PQDHMsoTbCYQqEAfpwVEi.json @@ -0,0 +1 @@ +{"changes":{"crates/vespera/Cargo.toml":"Patch","crates/vespera_core/Cargo.toml":"Patch","crates/vespera_macro/Cargo.toml":"Patch"},"note":"Support rel, nested object","date":"2026-02-03T17:14:35.195097800Z"} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index e6d5fd0..e61b714 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,6 @@ # VESPERA PROJECT KNOWLEDGE BASE -**Generated:** 2026-01-07 -**Commit:** 939a801 +**Generated:** 2026-02-04 **Branch:** main ## OVERVIEW @@ -13,7 +12,7 @@ Vespera is a fully automated OpenAPI 3.1 engine for Axum - delivers FastAPI-like ``` vespera/ ├── crates/ -│ ├── vespera/ # Public API - re-exports everything +│ ├── vespera/ # Public API - re-exports everything (+ chrono re-export) │ ├── vespera_core/ # OpenAPI types, route/schema abstractions │ └── vespera_macro/ # Proc-macros (main logic lives here) └── examples/axum-example/ # Demo app with route patterns @@ -28,6 +27,7 @@ vespera/ | Add route parser feature | `crates/vespera_macro/src/parser/` | Type extraction logic | | Change schema generation | `crates/vespera_macro/src/parser/schema.rs` | Rust→JSON Schema | | Modify route attribute | `crates/vespera_macro/src/args.rs` | `#[route]` parsing | +| Modify schema_type! macro | `crates/vespera_macro/src/schema_macro.rs` | Type derivation & SeaORM support | | Add core types | `crates/vespera_core/src/` | OpenAPI spec types | | Test new features | `examples/axum-example/` | Add route, run example | @@ -35,11 +35,43 @@ vespera/ | File | Lines | Role | |------|-------|------| -| `vespera_macro/src/lib.rs` | 1044 | `vespera!`, `#[route]`, `#[derive(Schema)]` | -| `vespera_macro/src/parser/schema.rs` | 1527 | Rust struct → JSON Schema conversion | -| `vespera_macro/src/parser/parameters.rs` | 845 | Extract path/query params from handlers | -| `vespera_macro/src/openapi_generator.rs` | 808 | OpenAPI doc assembly | -| `vespera_macro/src/collector.rs` | 707 | Filesystem route scanning | +| `vespera_macro/src/lib.rs` | ~1044 | `vespera!`, `#[route]`, `#[derive(Schema)]` | +| `vespera_macro/src/schema_macro.rs` | ~3000 | `schema_type!` macro, SeaORM relation handling | +| `vespera_macro/src/parser/schema.rs` | ~1527 | Rust struct → JSON Schema conversion | +| `vespera_macro/src/parser/parameters.rs` | ~845 | Extract path/query params from handlers | +| `vespera_macro/src/openapi_generator.rs` | ~808 | OpenAPI doc assembly | +| `vespera_macro/src/collector.rs` | ~707 | Filesystem route scanning | + +## SCHEMA_TYPE! MACRO + +Generate request/response types from existing structs with powerful transformations. + +### Key Features +- **Same-file Model reference**: `schema_type!(Schema from Model, name = "UserSchema")` - infers module path from file location +- **Cross-file reference**: `schema_type!(Response from crate::models::user::Model, omit = ["password"])` +- **SeaORM integration**: Automatic conversion of `HasOne`, `BelongsTo`, `HasMany` relations +- **Chrono conversion**: `DateTimeWithTimeZone` → `vespera::chrono::DateTime` +- **Circular reference handling**: Automatic detection and inline field generation + +### Parameters +| Parameter | Description | +|-----------|-------------| +| `pick` | Include only specified fields | +| `omit` | Exclude specified fields | +| `rename` | Rename fields: `[("old", "new")]` | +| `add` | Add new fields (disables auto `From`) | +| `clone` | Control Clone derive (default: true) | +| `partial` | Make fields optional for PATCH | +| `name` | Custom OpenAPI schema name | +| `rename_all` | Serde rename strategy | +| `ignore` | Skip Schema derive | + +### Module Path Resolution +When using simple `Model` path (no `crate::` prefix): +1. `find_struct_from_path()` calls `find_struct_by_name_in_all_files()` +2. Uses `schema_name` hint to disambiguate (e.g., "UserSchema" → prefers `user.rs`) +3. `file_path_to_module_path()` infers module path from file location +4. This enables `super::` resolution in relation types ## CONVENTIONS diff --git a/README.md b/README.md index e2c1cee..332a3ed 100644 --- a/README.md +++ b/README.md @@ -252,9 +252,27 @@ schema_type!(UserResponse from crate::models::user::Model, omit = ["password_has schema_type!(UpdateUserRequest from crate::models::user::Model, pick = ["name"], add = [("id": i32)]); ``` +### Same-File Model Reference + +When the model is in the same file, you can use a simple name with `name` parameter: + +```rust +// In src/models/user.rs +pub struct Model { + pub id: i32, + pub name: String, + pub email: String, +} + +// Simple `Model` path works when using `name` parameter +vespera::schema_type!(Schema from Model, name = "UserSchema"); +``` + +The macro infers the module path from the file location, so relation types like `HasOne` are resolved correctly. + ### Cross-File References -Reference structs from other files using module paths: +Reference structs from other files using full module paths: ```rust // In src/routes/users.rs - references src/models/user.rs @@ -273,6 +291,62 @@ let model: Model = db.find_user(id).await?; Json(model.into()) // Automatic conversion! ``` +### Partial Updates (PATCH) + +Use `partial` to make fields optional for PATCH-style updates: + +```rust +// All fields become Option +schema_type!(UserPatch from User, partial); + +// Only specific fields become Option +schema_type!(UserPatch from User, partial = ["name", "email"]); +``` + +### Serde Rename All + +Apply serde rename_all strategy: + +```rust +// Convert field names to camelCase in JSON +schema_type!(UserDTO from User, rename_all = "camelCase"); + +// Available: "camelCase", "snake_case", "PascalCase", "SCREAMING_SNAKE_CASE", etc. +``` + +### SeaORM Integration + +`schema_type!` has first-class support for SeaORM models with relations: + +```rust +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, DeriveEntityModel)] +#[sea_orm(table_name = "memos")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub title: String, + pub user_id: i32, + pub user: BelongsTo, // → Option> + pub comments: HasMany, // → Vec +} + +// Generates Schema with proper relation types +vespera::schema_type!(Schema from Model, name = "MemoSchema"); +``` + +**Relation Type Conversions:** + +| SeaORM Type | Generated Schema Type | +|-------------|----------------------| +| `HasOne` | `Box` or `Option>` | +| `BelongsTo` | `Option>` | +| `HasMany` | `Vec` | +| `DateTimeWithTimeZone` | `chrono::DateTime` | + +**Circular Reference Handling:** When schemas reference each other (e.g., User ↔ Memo), the macro automatically detects and handles circular references by inlining fields to prevent infinite recursion. + ### Parameters | Parameter | Description | @@ -282,6 +356,10 @@ Json(model.into()) // Automatic conversion! | `rename` | Rename fields: `rename = [("old", "new")]` | | `add` | Add new fields (disables auto `From` impl) | | `clone` | Control Clone derive (default: true) | +| `partial` | Make fields optional: `partial` or `partial = ["field1"]` | +| `name` | Custom OpenAPI schema name: `name = "UserSchema"` | +| `rename_all` | Serde rename strategy: `rename_all = "camelCase"` | +| `ignore` | Skip Schema derive (bare keyword, no value) | --- diff --git a/SKILL.md b/SKILL.md index de19126..812e80c 100644 --- a/SKILL.md +++ b/SKILL.md @@ -162,9 +162,32 @@ npx @apidevtools/swagger-cli validate openapi.json --- -## schema_type! Macro +## schema_type! Macro (RECOMMENDED) -Generate request/response types from existing structs with field filtering. Supports cross-file references and auto-generates `From` impl. +> **ALWAYS prefer `schema_type!` over manually defining request/response structs.** +> +> Benefits: +> - Single source of truth (your model) +> - Auto-generated `From` impl for easy conversion +> - Automatic type resolution (enums, custom types → absolute paths) +> - SeaORM relation support (HasOne, BelongsTo, HasMany) +> - No manual field synchronization + +### Why Not Manual Structs? + +```rust +// ❌ BAD: Manual struct definition - requires sync with Model +#[derive(Serialize, Deserialize, Schema)] +pub struct UserResponse { + pub id: i32, + pub name: String, + pub email: String, + // Forgot to add new field? Schema out of sync! +} + +// ✅ GOOD: Derive from Model - always in sync +schema_type!(UserResponse from crate::models::user::Model, omit = ["password_hash"]); +``` ### Basic Syntax @@ -181,10 +204,43 @@ schema_type!(UpdateUserRequest from crate::models::user::Model, pick = ["name"], // Rename fields schema_type!(UserDTO from crate::models::user::Model, rename = [("id", "user_id")]); +// Partial updates (all fields become Option) +schema_type!(UserPatch from crate::models::user::Model, partial); + +// Partial updates (specific fields only) +schema_type!(UserPatch from crate::models::user::Model, partial = ["name", "email"]); + +// Custom serde rename strategy +schema_type!(UserSnakeCase from crate::models::user::Model, rename_all = "snake_case"); + +// Custom OpenAPI schema name +schema_type!(Schema from Model, name = "UserSchema"); + +// Skip Schema derive (won't appear in OpenAPI) +schema_type!(InternalDTO from Model, ignore); + // Disable Clone derive schema_type!(LargeResponse from SomeType, clone = false); ``` +### Same-File Model Reference + +When the model is in the same file, use simple name with `name` parameter: + +```rust +// In src/models/user.rs +pub struct Model { + pub id: i32, + pub name: String, + pub status: UserStatus, // Custom enum - auto-resolved to absolute path +} + +pub enum UserStatus { Active, Inactive } + +// Simple `Model` path works - module path inferred from file location +vespera::schema_type!(Schema from Model, name = "UserSchema"); +``` + ### Cross-File References Reference structs from other files using full module paths: @@ -231,11 +287,51 @@ Json(model.into()) // Easy conversion! | `omit` | Exclude these fields | `omit = ["password"]` | | `rename` | Rename fields | `rename = [("id", "user_id")]` | | `add` | Add new fields (disables From impl) | `add = [("extra": String)]` | +| `partial` | Make fields optional for PATCH | `partial` or `partial = ["name"]` | +| `name` | Custom OpenAPI schema name | `name = "UserSchema"` | +| `rename_all` | Serde rename strategy | `rename_all = "camelCase"` | +| `ignore` | Skip Schema derive | bare keyword | | `clone` | Control Clone derive (default: true) | `clone = false` | -### Use Case: Sea-ORM Models +### SeaORM Integration (RECOMMENDED) + +`schema_type!` has first-class SeaORM support with automatic relation handling: + +```rust +// src/models/memo.rs +#[derive(Clone, Debug, DeriveEntityModel)] +#[sea_orm(table_name = "memo")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub title: String, + pub user_id: i32, + pub status: MemoStatus, // Custom enum + pub user: BelongsTo, // → Option> + pub comments: HasMany, // → Vec + pub created_at: DateTimeWithTimeZone, // → chrono::DateTime +} + +#[derive(EnumIter, DeriveActiveEnum, Serialize, Deserialize, Schema)] +pub enum MemoStatus { Draft, Published, Archived } + +// Generates Schema with proper types - no imports needed! +vespera::schema_type!(Schema from Model, name = "MemoSchema"); +``` + +**Automatic Type Conversions:** -Perfect for creating API types from database models: +| SeaORM Type | Generated Type | Notes | +|-------------|---------------|-------| +| `HasOne` | `Box` or `Option>` | Based on FK nullability | +| `BelongsTo` | `Option>` | Always optional | +| `HasMany` | `Vec` | | +| `DateTimeWithTimeZone` | `vespera::chrono::DateTime` | No SeaORM import needed | +| Custom enums | `crate::module::EnumName` | Auto-resolved to absolute path | + +**Circular Reference Handling:** Automatically detected and handled by inlining fields. + +### Complete Example ```rust // src/models/user.rs (Sea-ORM entity) @@ -246,19 +342,32 @@ pub struct Model { pub id: i32, pub name: String, pub email: String, + pub status: UserStatus, pub password_hash: String, // Never expose! pub created_at: DateTimeWithTimeZone, } -// src/routes/users.rs +// Generate Schema in same file - simple Model path +vespera::schema_type!(Schema from Model, name = "UserSchema"); + +// src/routes/users.rs - use full path for cross-file reference schema_type!(CreateUserRequest from crate::models::user::Model, pick = ["name", "email"]); schema_type!(UserResponse from crate::models::user::Model, omit = ["password_hash"]); +schema_type!(UserPatch from crate::models::user::Model, omit = ["password_hash", "id"], partial); #[vespera::route(get, path = "/{id}")] pub async fn get_user(Path(id): Path, State(db): State) -> Json { let user = User::find_by_id(id).one(&db).await.unwrap().unwrap(); Json(user.into()) // From impl handles conversion } + +#[vespera::route(patch, path = "/{id}")] +pub async fn patch_user( + Path(id): Path, + Json(patch): Json, // All fields are Option +) -> Json { + // Apply partial update... +} ``` --- diff --git a/crates/vespera_macro/src/schema_macro.rs b/crates/vespera_macro/src/schema_macro.rs index 693e24b..e956658 100644 --- a/crates/vespera_macro/src/schema_macro.rs +++ b/crates/vespera_macro/src/schema_macro.rs @@ -196,6 +196,106 @@ fn is_seaorm_model(struct_item: &syn::ItemStruct) -> bool { false } +/// Check if a type name is a primitive or well-known type that doesn't need path resolution. +fn is_primitive_or_known_type(name: &str) -> bool { + matches!( + name, + // Rust primitives + "bool" + | "char" + | "str" + | "i8" + | "i16" + | "i32" + | "i64" + | "i128" + | "isize" + | "u8" + | "u16" + | "u32" + | "u64" + | "u128" + | "usize" + | "f32" + | "f64" + // Common std types + | "String" + | "Vec" + | "Option" + | "Result" + | "Box" + | "Rc" + | "Arc" + | "HashMap" + | "HashSet" + | "BTreeMap" + | "BTreeSet" + // Chrono types + | "DateTime" + | "NaiveDateTime" + | "NaiveDate" + | "NaiveTime" + | "Utc" + | "Local" + | "FixedOffset" + // SeaORM types (will be converted separately) + | "DateTimeWithTimeZone" + | "DateTimeUtc" + | "DateTimeLocal" + // UUID + | "Uuid" + // Serde JSON + | "Value" + ) +} + +/// Resolve a simple type to an absolute path using the source module path. +/// +/// For example, if source_module_path is ["crate", "models", "memo"] and +/// the type is `MemoStatus`, it returns `crate::models::memo::MemoStatus`. +/// +/// If the type is already qualified (has `::`) or is a primitive/known type, +/// returns the original type unchanged. +fn resolve_type_to_absolute_path(ty: &Type, source_module_path: &[String]) -> TokenStream { + let type_path = match ty { + Type::Path(tp) => tp, + _ => return quote! { #ty }, + }; + + // If path has multiple segments (already qualified like `crate::foo::Bar`), return as-is + if type_path.path.segments.len() > 1 { + return quote! { #ty }; + } + + // Get the single segment + let segment = match type_path.path.segments.first() { + Some(s) => s, + None => return quote! { #ty }, + }; + + let ident_str = segment.ident.to_string(); + + // If it's a primitive or known type, return as-is + if is_primitive_or_known_type(&ident_str) { + return quote! { #ty }; + } + + // If no source module path, return as-is + if source_module_path.is_empty() { + return quote! { #ty }; + } + + // Build absolute path: source_module_path + type_name + let path_idents: Vec = source_module_path + .iter() + .map(|s| syn::Ident::new(s, proc_macro2::Span::call_site())) + .collect(); + let type_ident = &segment.ident; + let args = &segment.arguments; + + quote! { #(#path_idents)::* :: #type_ident #args } +} + /// Convert SeaORM datetime types to chrono equivalents. /// /// This allows generated schemas to use standard chrono types instead of @@ -210,7 +310,7 @@ fn is_seaorm_model(struct_item: &syn::ItemStruct) -> bool { /// - `Time` (SeaORM) → `chrono::NaiveTime` /// /// Returns the original type as TokenStream if not a SeaORM datetime type. -fn convert_seaorm_type_to_chrono(ty: &Type) -> TokenStream { +fn convert_seaorm_type_to_chrono(ty: &Type, source_module_path: &[String]) -> TokenStream { let type_path = match ty { Type::Path(tp) => tp, _ => return quote! { #ty }, @@ -230,9 +330,8 @@ fn convert_seaorm_type_to_chrono(ty: &Type) -> TokenStream { } "DateTimeUtc" => quote! { vespera::chrono::DateTime }, "DateTimeLocal" => quote! { vespera::chrono::DateTime }, - // Note: NaiveDateTime, NaiveDate, NaiveTime are already chrono types - // so they don't need conversion. Only convert SeaORM-specific aliases. - _ => quote! { #ty }, + // Not a SeaORM datetime type - resolve to absolute path if needed + _ => resolve_type_to_absolute_path(ty, source_module_path), } } @@ -240,7 +339,10 @@ fn convert_seaorm_type_to_chrono(ty: &Type) -> TokenStream { /// /// If the type is `Option`, converts to `Option`. /// If the type is just `SeaOrmType`, converts to `ChronoType`. -fn convert_type_with_chrono(ty: &Type) -> TokenStream { +/// +/// Also resolves local types (like `MemoStatus`) to absolute paths +/// (like `crate::models::memo::MemoStatus`) using source_module_path. +fn convert_type_with_chrono(ty: &Type, source_module_path: &[String]) -> TokenStream { // Check if it's Option if let Type::Path(type_path) = ty && let Some(segment) = type_path.path.segments.first() @@ -250,13 +352,27 @@ fn convert_type_with_chrono(ty: &Type) -> TokenStream { if let syn::PathArguments::AngleBracketed(args) = &segment.arguments && let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() { - let converted_inner = convert_seaorm_type_to_chrono(inner_ty); + let converted_inner = convert_seaorm_type_to_chrono(inner_ty, source_module_path); return quote! { Option<#converted_inner> }; } } - // Not Option, convert directly - convert_seaorm_type_to_chrono(ty) + // Check if it's Vec + if let Type::Path(type_path) = ty + && let Some(segment) = type_path.path.segments.first() + && segment.ident == "Vec" + { + // Extract the inner type from Vec + if let syn::PathArguments::AngleBracketed(args) = &segment.arguments + && let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() + { + let converted_inner = convert_seaorm_type_to_chrono(inner_ty, source_module_path); + return quote! { Vec<#converted_inner> }; + } + } + + // Not Option or Vec, convert directly + convert_seaorm_type_to_chrono(ty, source_module_path) } /// Relation field info for generating from_model code @@ -2706,7 +2822,8 @@ pub fn generate_schema_type_code( } } else { // Convert SeaORM datetime types to chrono equivalents - let converted_ty = convert_type_with_chrono(original_ty); + // Also resolves local types to absolute paths + let converted_ty = convert_type_with_chrono(original_ty, &source_module_path); if should_wrap_option { (Box::new(quote! { Option<#converted_ty> }), None) } else { diff --git a/examples/axum-example/models/memo.vespertide.json b/examples/axum-example/models/memo.vespertide.json index b63e813..ecfb1e5 100644 --- a/examples/axum-example/models/memo.vespertide.json +++ b/examples/axum-example/models/memo.vespertide.json @@ -12,6 +12,12 @@ }, { "name": "title", "type": { "kind": "varchar", "length": 200 }, "nullable": false }, { "name": "content", "type": "text", "nullable": false }, + { + "name": "status", + "type": { "kind": "enum", "name": "memo_status", "values": ["draft", "published", "archived"] }, + "nullable": false, + "default": "'draft'" + }, { "name": "created_at", "type": "timestamptz", "nullable": false, "default": "NOW()" }, { "name": "updated_at", "type": "timestamptz", "nullable": false, "default": "NOW()" } ] diff --git a/examples/axum-example/openapi.json b/examples/axum-example/openapi.json index 1901474..a87fac7 100644 --- a/examples/axum-example/openapi.json +++ b/examples/axum-example/openapi.json @@ -1980,6 +1980,9 @@ "id": { "type": "integer" }, + "status": { + "$ref": "#/components/schemas/MemoStatus" + }, "title": { "type": "string" }, @@ -1992,6 +1995,7 @@ "userId", "title", "content", + "status", "createdAt" ] }, @@ -2055,6 +2059,9 @@ "id": { "type": "integer" }, + "status": { + "$ref": "#/components/schemas/MemoStatus" + }, "title": { "type": "string" }, @@ -2070,6 +2077,7 @@ "userId", "title", "content", + "status", "createdAt", "user" ] @@ -2087,6 +2095,9 @@ "id": { "type": "integer" }, + "status": { + "$ref": "#/components/schemas/MemoStatus" + }, "title": { "type": "string" }, @@ -2106,6 +2117,7 @@ "userId", "title", "content", + "status", "createdAt", "updatedAt", "user" @@ -2131,6 +2143,14 @@ "created_at" ] }, + "MemoStatus": { + "type": "string", + "enum": [ + "draft", + "published", + "archived" + ] + }, "PaginatedResponse": { "type": "object", "properties": { diff --git a/examples/axum-example/src/lib.rs b/examples/axum-example/src/lib.rs index 9ce057e..2fe2ed2 100644 --- a/examples/axum-example/src/lib.rs +++ b/examples/axum-example/src/lib.rs @@ -21,7 +21,7 @@ pub struct TestStruct { /// Create the application router for testing pub async fn create_app() -> axum::Router { - let db = Database::connect("sqlite://:memory:").await.unwrap(); + let db = Database::connect("sqlite::memory:").await.unwrap(); vespera!( openapi = ["examples/axum-example/openapi.json", "openapi.json"], docs_url = "/docs", @@ -43,7 +43,7 @@ pub async fn create_app_with_layer() -> axum::Router { .allow_methods(Any) .allow_headers(Any); - let db = Database::connect("sqlite://:memory:").await.unwrap(); + let db = Database::connect("sqlite::memory:").await.unwrap(); vespera!( openapi = ["examples/axum-example/openapi.json", "openapi.json"], docs_url = "/docs", diff --git a/examples/axum-example/src/models/comment.rs b/examples/axum-example/src/models/comment.rs index 63598a0..b63bac7 100644 --- a/examples/axum-example/src/models/comment.rs +++ b/examples/axum-example/src/models/comment.rs @@ -21,10 +21,9 @@ pub struct Model { pub memo: HasOne, } -// Schema WITH both user and memo relations -// This tests complex circular reference handling: -// - Comment.user -> User (which has memos and comments) -// - Comment.memo -> Memo (which has user and comments) vespera::schema_type!(Schema from Model, name = "CommentSchema"); +// Index definitions (SeaORM uses Statement builders externally) +// (unnamed) on [user_id] +// (unnamed) on [memo_id] impl ActiveModelBehavior for ActiveModel {} diff --git a/examples/axum-example/src/models/memo.rs b/examples/axum-example/src/models/memo.rs index 604e2ac..0d2ca09 100644 --- a/examples/axum-example/src/models/memo.rs +++ b/examples/axum-example/src/models/memo.rs @@ -1,4 +1,19 @@ 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 = "memo_memo_status")] +pub enum MemoStatus { + #[sea_orm(string_value = "draft")] + Draft, + #[sea_orm(string_value = "published")] + Published, + #[sea_orm(string_value = "archived")] + Archived, +} #[sea_orm::model] #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] @@ -10,6 +25,8 @@ pub struct Model { pub user_id: i32, pub title: String, pub content: String, + #[sea_orm(default_value = "draft")] + pub status: MemoStatus, #[sea_orm(default_value = "NOW()")] pub created_at: DateTimeWithTimeZone, #[sea_orm(default_value = "NOW()")] diff --git a/examples/axum-example/src/models/user.rs b/examples/axum-example/src/models/user.rs index 50589a5..ea58935 100644 --- a/examples/axum-example/src/models/user.rs +++ b/examples/axum-example/src/models/user.rs @@ -14,9 +14,9 @@ pub struct Model { #[sea_orm(default_value = "NOW()")] pub updated_at: DateTimeWithTimeZone, #[sea_orm(has_many)] - pub memos: HasMany, - #[sea_orm(has_many)] pub comments: HasMany, + #[sea_orm(has_many)] + pub memos: HasMany, } // Schema WITH memos relation - circular refs are auto-handled diff --git a/examples/axum-example/src/routes/memos.rs b/examples/axum-example/src/routes/memos.rs index f26e660..fb3e0a6 100644 --- a/examples/axum-example/src/routes/memos.rs +++ b/examples/axum-example/src/routes/memos.rs @@ -76,6 +76,7 @@ pub async fn get_memo(Path(id): Path) -> Json { user_id: 1, // Example user ID title: "Test Memo".to_string(), content: "This is test content".to_string(), + status: crate::models::memo::MemoStatus::Published, created_at: DateTimeWithTimeZone::default(), updated_at: DateTimeWithTimeZone::default(), }; @@ -94,6 +95,7 @@ pub async fn get_memo_rel( user_id: 1, // Example user ID title: "Test Memo".to_string(), content: "This is test content".to_string(), + status: crate::models::memo::MemoStatus::Published, created_at: DateTimeWithTimeZone::default(), updated_at: DateTimeWithTimeZone::default(), }; diff --git a/examples/axum-example/tests/integration_test.rs b/examples/axum-example/tests/integration_test.rs index 2a124b8..14ef78f 100644 --- a/examples/axum-example/tests/integration_test.rs +++ b/examples/axum-example/tests/integration_test.rs @@ -645,11 +645,12 @@ async fn test_create_user_with_meta_add_fields() { let server = TestServer::new(app).unwrap(); // CreateUserWithMeta has: name, email (from User) + request_id, created_at (added) + // Note: Field names are camelCase in JSON due to serde rename_all = "camelCase" let request_body = json!({ "name": "Test User", "email": "test@example.com", - "request_id": "req-12345", - "created_at": null + "requestId": "req-12345", + "createdAt": null }); let response = server.post("/users/with-meta").json(&request_body).await; @@ -661,9 +662,9 @@ async fn test_create_user_with_meta_add_fields() { assert_eq!(result["name"], "Test User"); assert_eq!(result["email"], "test@example.com"); - // Verify added fields - assert_eq!(result["request_id"], "req-12345"); - assert_eq!(result["created_at"], "2024-01-27T12:00:00Z"); // Server fills this in + // Verify added fields (camelCase in JSON) + assert_eq!(result["requestId"], "req-12345"); + assert_eq!(result["createdAt"], "2024-01-27T12:00:00Z"); // Server fills this in } // Tests for schema_type! with sea-orm-like models diff --git a/examples/axum-example/tests/snapshots/integration_test__openapi.snap b/examples/axum-example/tests/snapshots/integration_test__openapi.snap index 54ec628..8bdf828 100644 --- a/examples/axum-example/tests/snapshots/integration_test__openapi.snap +++ b/examples/axum-example/tests/snapshots/integration_test__openapi.snap @@ -1984,6 +1984,9 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "id": { "type": "integer" }, + "status": { + "$ref": "#/components/schemas/MemoStatus" + }, "title": { "type": "string" }, @@ -1996,6 +1999,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "userId", "title", "content", + "status", "createdAt" ] }, @@ -2059,6 +2063,9 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "id": { "type": "integer" }, + "status": { + "$ref": "#/components/schemas/MemoStatus" + }, "title": { "type": "string" }, @@ -2074,6 +2081,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "userId", "title", "content", + "status", "createdAt", "user" ] @@ -2091,6 +2099,9 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "id": { "type": "integer" }, + "status": { + "$ref": "#/components/schemas/MemoStatus" + }, "title": { "type": "string" }, @@ -2110,6 +2121,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "userId", "title", "content", + "status", "createdAt", "updatedAt", "user" @@ -2135,6 +2147,14 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "created_at" ] }, + "MemoStatus": { + "type": "string", + "enum": [ + "draft", + "published", + "archived" + ] + }, "PaginatedResponse": { "type": "object", "properties": { diff --git a/openapi.json b/openapi.json index 1901474..a87fac7 100644 --- a/openapi.json +++ b/openapi.json @@ -1980,6 +1980,9 @@ "id": { "type": "integer" }, + "status": { + "$ref": "#/components/schemas/MemoStatus" + }, "title": { "type": "string" }, @@ -1992,6 +1995,7 @@ "userId", "title", "content", + "status", "createdAt" ] }, @@ -2055,6 +2059,9 @@ "id": { "type": "integer" }, + "status": { + "$ref": "#/components/schemas/MemoStatus" + }, "title": { "type": "string" }, @@ -2070,6 +2077,7 @@ "userId", "title", "content", + "status", "createdAt", "user" ] @@ -2087,6 +2095,9 @@ "id": { "type": "integer" }, + "status": { + "$ref": "#/components/schemas/MemoStatus" + }, "title": { "type": "string" }, @@ -2106,6 +2117,7 @@ "userId", "title", "content", + "status", "createdAt", "updatedAt", "user" @@ -2131,6 +2143,14 @@ "created_at" ] }, + "MemoStatus": { + "type": "string", + "enum": [ + "draft", + "published", + "archived" + ] + }, "PaginatedResponse": { "type": "object", "properties": {