From b533c3c7c0dc99ad4a8f748e1325d9c069a8412d Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 19 Feb 2026 01:03:25 +0900 Subject: [PATCH 1/6] Fix required issue --- Cargo.lock | 6 +- crates/vespera_macro/src/schema_macro/mod.rs | 64 +----- .../vespera_macro/src/schema_macro/tests.rs | 186 +----------------- examples/axum-example/openapi.json | 19 +- .../snapshots/integration_test__openapi.snap | 19 +- openapi.json | 19 +- 6 files changed, 50 insertions(+), 263 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index eca143a..6bfa39a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3237,7 +3237,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vespera" -version = "0.1.36" +version = "0.1.37" dependencies = [ "axum", "axum-extra", @@ -3253,7 +3253,7 @@ dependencies = [ [[package]] name = "vespera_core" -version = "0.1.36" +version = "0.1.37" dependencies = [ "rstest", "serde", @@ -3262,7 +3262,7 @@ dependencies = [ [[package]] name = "vespera_macro" -version = "0.1.36" +version = "0.1.37" dependencies = [ "insta", "proc-macro2", diff --git a/crates/vespera_macro/src/schema_macro/mod.rs b/crates/vespera_macro/src/schema_macro/mod.rs index e3ae68d..0fcd420 100644 --- a/crates/vespera_macro/src/schema_macro/mod.rs +++ b/crates/vespera_macro/src/schema_macro/mod.rs @@ -656,24 +656,8 @@ fn generate_sea_orm_default_attrs( }; // SQL functions like NOW(), CURRENT_TIMESTAMP(), gen_random_uuid() - // can't be expressed as concrete JSON defaults, but we can make the field - // not-required by generating serde(default) with a dummy default value. + // can't be expressed as concrete JSON defaults — skip entirely. if is_sql_function_default(&default_value) { - let has_existing_serde_default = extract_default(original_attrs).is_some(); - if !has_existing_serde_default - && let Some(default_body) = sql_function_default_body(original_ty) - { - let fn_name = format!("default_{struct_name}_{field_name}"); - let fn_ident = syn::Ident::new(&fn_name, proc_macro2::Span::call_site()); - default_functions.push(quote! { - #[allow(non_snake_case)] - fn #fn_ident() -> #field_ty { - #default_body - } - }); - let serde_default_attr = quote! { #[serde(default = #fn_name)] }; - return (serde_default_attr, quote! {}); - } return (quote! {}, quote! {}); } @@ -708,52 +692,6 @@ fn generate_sea_orm_default_attrs( (serde_default_attr, schema_default_attr) } -/// Generate a dummy default expression for types with SQL function defaults -/// (e.g., `gen_random_uuid()`, `NOW()`). -/// -/// Returns `Some(TokenStream)` with the default expression for supported types, -/// `None` for types where a dummy default cannot be generated. -/// -/// These defaults exist solely to satisfy `serde(default)` so the field becomes -/// not-required in OpenAPI. The actual value is irrelevant since the database -/// provides the real default. -fn sql_function_default_body(original_ty: &syn::Type) -> Option { - let syn::Type::Path(type_path) = original_ty else { - return None; - }; - let segment = type_path.path.segments.last()?; - let type_name = segment.ident.to_string(); - - match type_name.as_str() { - // Types implementing Default (returns zero/empty values) - "i8" | "i16" | "i32" | "i64" | "i128" | "isize" | "u8" | "u16" | "u32" | "u64" | "u128" - | "usize" | "f32" | "f64" | "bool" | "String" | "Decimal" | "Uuid" => { - Some(quote! { Default::default() }) - } - // SeaORM datetime types → chrono epoch - "DateTimeWithTimeZone" => Some(quote! { - vespera::chrono::DateTime::::UNIX_EPOCH.fixed_offset() - }), - "DateTimeUtc" => Some(quote! { - vespera::chrono::DateTime::::UNIX_EPOCH - }), - "DateTimeLocal" => Some(quote! { - vespera::chrono::DateTime::::UNIX_EPOCH - .with_timezone(&vespera::chrono::Local) - }), - "DateTime" | "NaiveDateTime" => Some(quote! { - vespera::chrono::NaiveDateTime::UNIX_EPOCH - }), - "NaiveDate" | "Date" => Some(quote! { - vespera::chrono::NaiveDate::from_ymd_opt(1970, 1, 1).unwrap() - }), - "NaiveTime" | "Time" => Some(quote! { - vespera::chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap() - }), - _ => None, - } -} - /// Check if a type is known to implement `FromStr` and can use `.parse().unwrap()`. /// /// Returns true for primitive types, String, and Decimal. diff --git a/crates/vespera_macro/src/schema_macro/tests.rs b/crates/vespera_macro/src/schema_macro/tests.rs index b6f828d..ffdf565 100644 --- a/crates/vespera_macro/src/schema_macro/tests.rs +++ b/crates/vespera_macro/src/schema_macro/tests.rs @@ -311,7 +311,7 @@ fn test_sea_orm_default_attrs_no_default_value() { } #[test] -fn test_sea_orm_default_attrs_sql_function_supported_type() { +fn test_sea_orm_default_attrs_sql_function_skips() { let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "NOW()")])]; let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); let ty: syn::Type = syn::parse_str("String").unwrap(); @@ -325,42 +325,8 @@ fn test_sea_orm_default_attrs_sql_function_supported_type() { false, &mut fns, ); - // Supported type with SQL function → generates serde(default) to mark field not-required - let serde_str = serde.to_string(); - assert!( - serde_str.contains("serde"), - "should have serde default attr: {serde_str}" - ); - assert!( - serde_str.contains("default_Test_created_at"), - "should reference generated default fn: {serde_str}" - ); - // No JSON default for SQL functions (value is DB-side only) - assert!(schema.is_empty()); - // Default function was generated - assert_eq!(fns.len(), 1, "should generate one default function"); -} - -#[test] -fn test_sea_orm_default_attrs_sql_function_unsupported_type_skips() { - let attrs: Vec = - vec![syn::parse_quote!(#[sea_orm(default_value = "MY_FUNC()")])]; - let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); - let ty: syn::Type = syn::parse_str("MyCustomType").unwrap(); - let mut fns = Vec::new(); - let (serde, schema) = generate_sea_orm_default_attrs( - &attrs, - &struct_name, - "custom_field", - &ty, - &ty, - false, - &mut fns, - ); - // Unsupported type with SQL function → still skips entirely assert!(serde.is_empty()); assert!(schema.is_empty()); - assert!(fns.is_empty()); } #[test] @@ -477,156 +443,6 @@ fn test_generate_schema_type_code_with_partial_fields() { ); } -// --- Coverage: sql_function_default_body branches --- - -#[test] -fn test_sql_function_default_non_path_type_skips() { - // Reference type (&str) is Type::Reference, not Type::Path → sql_function_default_body returns None - let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "GEN()")])]; - let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); - let ty: syn::Type = syn::parse_str("&str").unwrap(); - let mut fns = Vec::new(); - let (serde, schema) = - generate_sea_orm_default_attrs(&attrs, &struct_name, "val", &ty, &ty, false, &mut fns); - assert!(serde.is_empty()); - assert!(schema.is_empty()); - assert!(fns.is_empty()); -} - -#[test] -fn test_sql_function_default_datetime_with_timezone() { - let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "NOW()")])]; - let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); - let ty: syn::Type = syn::parse_str("DateTimeWithTimeZone").unwrap(); - let mut fns = Vec::new(); - let (serde, schema) = - generate_sea_orm_default_attrs(&attrs, &struct_name, "ts", &ty, &ty, false, &mut fns); - let serde_str = serde.to_string(); - assert!( - serde_str.contains("serde"), - "should have serde attr: {serde_str}" - ); - assert!(schema.is_empty()); - assert_eq!(fns.len(), 1); - let fn_str = fns[0].to_string(); - assert!( - fn_str.contains("UNIX_EPOCH"), - "should use epoch default: {fn_str}" - ); -} - -#[test] -fn test_sql_function_default_datetime_utc() { - let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "NOW()")])]; - let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); - let ty: syn::Type = syn::parse_str("DateTimeUtc").unwrap(); - let mut fns = Vec::new(); - let (serde, schema) = - generate_sea_orm_default_attrs(&attrs, &struct_name, "ts", &ty, &ty, false, &mut fns); - let serde_str = serde.to_string(); - assert!( - serde_str.contains("serde"), - "should have serde attr: {serde_str}" - ); - assert!(schema.is_empty()); - assert_eq!(fns.len(), 1); - let fn_str = fns[0].to_string(); - assert!( - fn_str.contains("UNIX_EPOCH"), - "should use epoch default: {fn_str}" - ); -} - -#[test] -fn test_sql_function_default_datetime_local() { - let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "NOW()")])]; - let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); - let ty: syn::Type = syn::parse_str("DateTimeLocal").unwrap(); - let mut fns = Vec::new(); - let (serde, schema) = - generate_sea_orm_default_attrs(&attrs, &struct_name, "ts", &ty, &ty, false, &mut fns); - let serde_str = serde.to_string(); - assert!( - serde_str.contains("serde"), - "should have serde attr: {serde_str}" - ); - assert!(schema.is_empty()); - assert_eq!(fns.len(), 1); - let fn_str = fns[0].to_string(); - assert!( - fn_str.contains("UNIX_EPOCH"), - "should use epoch default: {fn_str}" - ); -} - -#[test] -fn test_sql_function_default_naive_datetime() { - let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "NOW()")])]; - let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); - let ty: syn::Type = syn::parse_str("NaiveDateTime").unwrap(); - let mut fns = Vec::new(); - let (serde, schema) = - generate_sea_orm_default_attrs(&attrs, &struct_name, "ts", &ty, &ty, false, &mut fns); - let serde_str = serde.to_string(); - assert!( - serde_str.contains("serde"), - "should have serde attr: {serde_str}" - ); - assert!(schema.is_empty()); - assert_eq!(fns.len(), 1); - let fn_str = fns[0].to_string(); - assert!( - fn_str.contains("UNIX_EPOCH"), - "should use epoch default: {fn_str}" - ); -} - -#[test] -fn test_sql_function_default_naive_date() { - let attrs: Vec = - vec![syn::parse_quote!(#[sea_orm(default_value = "CURDATE()")])]; - let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); - let ty: syn::Type = syn::parse_str("NaiveDate").unwrap(); - let mut fns = Vec::new(); - let (serde, schema) = - generate_sea_orm_default_attrs(&attrs, &struct_name, "d", &ty, &ty, false, &mut fns); - let serde_str = serde.to_string(); - assert!( - serde_str.contains("serde"), - "should have serde attr: {serde_str}" - ); - assert!(schema.is_empty()); - assert_eq!(fns.len(), 1); - let fn_str = fns[0].to_string(); - assert!( - fn_str.contains("from_ymd_opt"), - "should use ymd default: {fn_str}" - ); -} - -#[test] -fn test_sql_function_default_naive_time() { - let attrs: Vec = - vec![syn::parse_quote!(#[sea_orm(default_value = "CURTIME()")])]; - let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); - let ty: syn::Type = syn::parse_str("NaiveTime").unwrap(); - let mut fns = Vec::new(); - let (serde, schema) = - generate_sea_orm_default_attrs(&attrs, &struct_name, "t", &ty, &ty, false, &mut fns); - let serde_str = serde.to_string(); - assert!( - serde_str.contains("serde"), - "should have serde attr: {serde_str}" - ); - assert!(schema.is_empty()); - assert_eq!(fns.len(), 1); - let fn_str = fns[0].to_string(); - assert!( - fn_str.contains("from_hms_opt"), - "should use hms default: {fn_str}" - ); -} - // --- Coverage: is_parseable_type empty segments --- #[test] diff --git a/examples/axum-example/openapi.json b/examples/axum-example/openapi.json index 903c75f..e9ecffb 100644 --- a/examples/axum-example/openapi.json +++ b/examples/axum-example/openapi.json @@ -3045,6 +3045,8 @@ "userId", "memoId", "content", + "createdAt", + "updatedAt", "user", "memo" ] @@ -3079,7 +3081,8 @@ "userId", "title", "content", - "status" + "status", + "createdAt" ] }, "MemoResponseComments": { @@ -3166,6 +3169,7 @@ "title", "content", "status", + "createdAt", "user" ] }, @@ -3207,6 +3211,8 @@ "title", "content", "status", + "createdAt", + "updatedAt", "user" ] }, @@ -3228,7 +3234,8 @@ }, "required": [ "id", - "user_id" + "user_id", + "created_at" ] }, "MemoStatus": { @@ -3919,7 +3926,9 @@ "required": [ "id", "email", - "name" + "name", + "createdAt", + "updatedAt" ] }, "UserSummary": { @@ -3986,7 +3995,9 @@ } }, "required": [ - "name" + "id", + "name", + "createdAt" ] } } diff --git a/examples/axum-example/tests/snapshots/integration_test__openapi.snap b/examples/axum-example/tests/snapshots/integration_test__openapi.snap index 7b9f831..ad18b8c 100644 --- a/examples/axum-example/tests/snapshots/integration_test__openapi.snap +++ b/examples/axum-example/tests/snapshots/integration_test__openapi.snap @@ -3049,6 +3049,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "userId", "memoId", "content", + "createdAt", + "updatedAt", "user", "memo" ] @@ -3083,7 +3085,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "userId", "title", "content", - "status" + "status", + "createdAt" ] }, "MemoResponseComments": { @@ -3170,6 +3173,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "title", "content", "status", + "createdAt", "user" ] }, @@ -3211,6 +3215,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "title", "content", "status", + "createdAt", + "updatedAt", "user" ] }, @@ -3232,7 +3238,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" }, "required": [ "id", - "user_id" + "user_id", + "created_at" ] }, "MemoStatus": { @@ -3923,7 +3930,9 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "required": [ "id", "email", - "name" + "name", + "createdAt", + "updatedAt" ] }, "UserSummary": { @@ -3990,7 +3999,9 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" } }, "required": [ - "name" + "id", + "name", + "createdAt" ] } } diff --git a/openapi.json b/openapi.json index 903c75f..e9ecffb 100644 --- a/openapi.json +++ b/openapi.json @@ -3045,6 +3045,8 @@ "userId", "memoId", "content", + "createdAt", + "updatedAt", "user", "memo" ] @@ -3079,7 +3081,8 @@ "userId", "title", "content", - "status" + "status", + "createdAt" ] }, "MemoResponseComments": { @@ -3166,6 +3169,7 @@ "title", "content", "status", + "createdAt", "user" ] }, @@ -3207,6 +3211,8 @@ "title", "content", "status", + "createdAt", + "updatedAt", "user" ] }, @@ -3228,7 +3234,8 @@ }, "required": [ "id", - "user_id" + "user_id", + "created_at" ] }, "MemoStatus": { @@ -3919,7 +3926,9 @@ "required": [ "id", "email", - "name" + "name", + "createdAt", + "updatedAt" ] }, "UserSummary": { @@ -3986,7 +3995,9 @@ } }, "required": [ - "name" + "id", + "name", + "createdAt" ] } } From 34d40eae56960812f7f59894e58ba3c8b9645434 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 19 Feb 2026 01:13:57 +0900 Subject: [PATCH 2/6] Support Set --- .../changepack_log_ZNL3OcXgV7tpTmN1Q93Z0.json | 1 + crates/vespera_macro/src/parser/parameters.rs | 2 +- .../src/parser/schema/type_schema.rs | 90 ++++++++++++++++++- examples/axum-example/openapi.json | 22 ++++- .../axum-example/src/routes/uuid_items.rs | 8 ++ .../snapshots/integration_test__openapi.snap | 22 ++++- openapi.json | 22 ++++- 7 files changed, 158 insertions(+), 9 deletions(-) create mode 100644 .changepacks/changepack_log_ZNL3OcXgV7tpTmN1Q93Z0.json diff --git a/.changepacks/changepack_log_ZNL3OcXgV7tpTmN1Q93Z0.json b/.changepacks/changepack_log_ZNL3OcXgV7tpTmN1Q93Z0.json new file mode 100644 index 0000000..5307985 --- /dev/null +++ b/.changepacks/changepack_log_ZNL3OcXgV7tpTmN1Q93Z0.json @@ -0,0 +1 @@ +{"changes":{"Cargo.toml":"Patch"},"note":"Fix required issue, Support Set","date":"2026-02-18T16:13:52.003330500Z"} \ No newline at end of file diff --git a/crates/vespera_macro/src/parser/parameters.rs b/crates/vespera_macro/src/parser/parameters.rs index b814d57..f41a05c 100644 --- a/crates/vespera_macro/src/parser/parameters.rs +++ b/crates/vespera_macro/src/parser/parameters.rs @@ -316,7 +316,7 @@ fn is_known_type( // Check for generic types like Vec, Option - recursively check inner type if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { match ident_str.as_str() { - "Vec" | "Option" => { + "Vec" | "HashSet" | "BTreeSet" | "Option" => { if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() { return is_known_type(inner_ty, known_schemas, struct_definitions); } diff --git a/crates/vespera_macro/src/parser/schema/type_schema.rs b/crates/vespera_macro/src/parser/schema/type_schema.rs index 9b0fadd..1878c17 100644 --- a/crates/vespera_macro/src/parser/schema/type_schema.rs +++ b/crates/vespera_macro/src/parser/schema/type_schema.rs @@ -129,7 +129,7 @@ fn parse_type_impl( ); } } - "Vec" | "Option" => { + "Vec" | "HashSet" | "BTreeSet" | "Option" => { if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() { let inner_schema = parse_type_to_schema_ref( inner_ty, @@ -139,6 +139,11 @@ fn parse_type_impl( if ident_str == "Vec" { return SchemaRef::Inline(Box::new(Schema::array(inner_schema))); } + if ident_str == "HashSet" || ident_str == "BTreeSet" { + let mut schema = Schema::array(inner_schema); + schema.unique_items = Some(true); + return SchemaRef::Inline(Box::new(schema)); + } // Option -> nullable schema match inner_schema { SchemaRef::Inline(mut schema) => { @@ -322,7 +327,7 @@ fn parse_type_impl( })), // Standard library types that should not be referenced // Note: HashMap and BTreeMap are handled above in generic types - "Vec" | "Option" | "Result" | "Json" | "Path" | "Query" | "Header" => { + "Vec" | "HashSet" | "BTreeSet" | "Option" | "Result" | "Json" | "Path" | "Query" | "Header" => { // These are not schema types, return object schema SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object))) } @@ -1417,4 +1422,85 @@ mod tests { panic!("Expected inline string schema for &mut String"); } } + + // ========== Coverage: HashSet/BTreeSet → uniqueItems ========== + + #[test] + fn test_hashset_string_produces_unique_items_array() { + let ty: Type = syn::parse_str("HashSet").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); + if let SchemaRef::Inline(schema) = &schema_ref { + assert_eq!(schema.schema_type, Some(SchemaType::Array)); + assert_eq!(schema.unique_items, Some(true)); + if let Some(SchemaRef::Inline(items)) = schema.items.as_deref() { + assert_eq!(items.schema_type, Some(SchemaType::String)); + } else { + panic!("Expected inline string items for HashSet"); + } + } else { + panic!("Expected inline schema for HashSet"); + } + } + + #[test] + fn test_btreeset_i32_produces_unique_items_array() { + let ty: Type = syn::parse_str("BTreeSet").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); + if let SchemaRef::Inline(schema) = &schema_ref { + assert_eq!(schema.schema_type, Some(SchemaType::Array)); + assert_eq!(schema.unique_items, Some(true)); + if let Some(SchemaRef::Inline(items)) = schema.items.as_deref() { + assert_eq!(items.schema_type, Some(SchemaType::Integer)); + } else { + panic!("Expected inline integer items for BTreeSet"); + } + } else { + panic!("Expected inline schema for BTreeSet"); + } + } + + #[test] + fn test_option_hashset_is_nullable_unique_array() { + let ty: Type = syn::parse_str("Option>").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); + if let SchemaRef::Inline(schema) = &schema_ref { + assert_eq!(schema.schema_type, Some(SchemaType::Array)); + assert_eq!(schema.unique_items, Some(true)); + assert_eq!(schema.nullable, Some(true)); + if let Some(SchemaRef::Inline(items)) = schema.items.as_deref() { + assert_eq!(items.schema_type, Some(SchemaType::Integer)); + } else { + panic!("Expected inline integer items for Option>"); + } + } else { + panic!("Expected inline schema for Option>"); + } + } + + #[test] + fn test_vec_does_not_have_unique_items() { + let ty: Type = syn::parse_str("Vec").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); + if let SchemaRef::Inline(schema) = &schema_ref { + assert_eq!(schema.schema_type, Some(SchemaType::Array)); + assert!(schema.unique_items.is_none()); + } else { + panic!("Expected inline schema for Vec"); + } + } + + #[test] + fn test_bare_hashset_without_generics() { + // HashSet without angle brackets → falls through to bare-name match + let ty: Type = syn::parse_str("HashSet").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); + assert!(matches!(schema_ref, SchemaRef::Inline(_))); + } + + #[test] + fn test_bare_btreeset_without_generics() { + let ty: Type = syn::parse_str("BTreeSet").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &HashSet::new(), &HashMap::new()); + assert!(matches!(schema_ref, SchemaRef::Inline(_))); + } } diff --git a/examples/axum-example/openapi.json b/examples/axum-example/openapi.json index e9ecffb..5bc6072 100644 --- a/examples/axum-example/openapi.json +++ b/examples/axum-example/openapi.json @@ -2500,10 +2500,19 @@ }, "name": { "type": "string" + }, + "tags": { + "type": "array", + "description": "Unique tags for this item", + "items": { + "type": "string" + }, + "uniqueItems": true } }, "required": [ - "name" + "name", + "tags" ] }, "Enum": { @@ -3962,11 +3971,20 @@ }, "name": { "type": "string" + }, + "tags": { + "type": "array", + "description": "Unique tags for this item", + "items": { + "type": "string" + }, + "uniqueItems": true } }, "required": [ "id", - "name" + "name", + "tags" ] }, "UuidItemSchema": { diff --git a/examples/axum-example/src/routes/uuid_items.rs b/examples/axum-example/src/routes/uuid_items.rs index eeb8096..0b09f24 100644 --- a/examples/axum-example/src/routes/uuid_items.rs +++ b/examples/axum-example/src/routes/uuid_items.rs @@ -1,3 +1,5 @@ +use std::collections::BTreeSet; + use serde::{Deserialize, Serialize}; use uuid::Uuid; use vespera::Schema; @@ -8,12 +10,16 @@ pub struct UuidItem { pub id: Uuid, pub name: String, pub external_ref: Option, + /// Unique tags for this item + pub tags: BTreeSet, } #[derive(Deserialize, Schema)] pub struct CreateUuidItemRequest { pub name: String, pub external_ref: Option, + /// Unique tags for this item + pub tags: BTreeSet, } /// List all UUID items @@ -29,6 +35,7 @@ pub async fn list_uuid_items() -> Json> { id: Uuid::new_v4(), name: "example".to_string(), external_ref: Some(Uuid::new_v4()), + tags: BTreeSet::new(), }]) } @@ -39,5 +46,6 @@ pub async fn create_uuid_item(Json(req): Json) -> Json Date: Thu, 19 Feb 2026 01:48:23 +0900 Subject: [PATCH 3/6] Fix default issue --- .../src/parser/schema/struct_schema.rs | 83 ++++----- crates/vespera_macro/src/schema_macro/mod.rs | 163 ++++++++++++++---- .../vespera_macro/src/schema_macro/seaorm.rs | 55 ++++++ .../vespera_macro/src/schema_macro/tests.rs | 81 ++++++++- .../src/schema_macro/type_utils.rs | 22 ++- examples/axum-example/openapi.json | 71 +++++--- .../snapshots/integration_test__openapi.snap | 71 +++++--- openapi.json | 71 +++++--- 8 files changed, 453 insertions(+), 164 deletions(-) diff --git a/crates/vespera_macro/src/parser/schema/struct_schema.rs b/crates/vespera_macro/src/parser/schema/struct_schema.rs index 4841789..4097f47 100644 --- a/crates/vespera_macro/src/parser/schema/struct_schema.rs +++ b/crates/vespera_macro/src/parser/schema/struct_schema.rs @@ -10,9 +10,8 @@ use vespera_core::schema::{Schema, SchemaRef, SchemaType}; use super::{ serde_attrs::{ - extract_default, extract_doc_comment, extract_field_rename, extract_flatten, - extract_rename_all, extract_skip, extract_skip_serializing_if, rename_field, - strip_raw_prefix, + extract_doc_comment, extract_field_rename, extract_flatten, extract_rename_all, + extract_skip, rename_field, strip_raw_prefix, }, type_schema::parse_type_to_schema_ref, }; @@ -104,36 +103,21 @@ pub fn parse_struct_to_schema( } } - // Check for default attribute - let has_default = extract_default(&field.attrs).is_some(); - - // Check for skip_serializing_if attribute - let has_skip_serializing_if = extract_skip_serializing_if(&field.attrs); + // Required is determined solely by nullability (Option). + // Fields with #[serde(default)] still have defaults applied in + // openapi_generator, but that does NOT affect required status. + let is_optional = matches!( + field_type, + Type::Path(type_path) + if type_path + .path + .segments + .first() + .is_some_and(|s| s.ident == "Option") + ); - // If default or skip_serializing_if is present, mark field as optional (not required) - // and set default value if it's a simple default (not a function) - if has_default || has_skip_serializing_if { - // For default = "function_name", we'll handle it in openapi_generator - // For now, just mark as optional - if let SchemaRef::Inline(ref mut _schema) = schema_ref { - // Default will be set later in openapi_generator if it's a function - // For simple default, we could set it here, but serde handles it - } - } else { - // Check if field is Option - let is_optional = matches!( - field_type, - Type::Path(type_path) - if type_path - .path - .segments - .first() - .is_some_and(|s| s.ident == "Option") - ); - - if !is_optional { - required.push(field_name.clone()); - } + if !is_optional { + required.push(field_name.clone()); } properties.insert(field_name, schema_ref); @@ -212,20 +196,16 @@ mod tests { let props = schema.properties.as_ref().unwrap(); assert!(props.contains_key("id")); assert!(props.contains_key("name")); - assert!( - schema - .required - .as_ref() - .unwrap() - .contains(&"id".to_string()) - ); - assert!( - !schema - .required - .as_ref() - .unwrap() - .contains(&"name".to_string()) - ); + assert!(schema + .required + .as_ref() + .unwrap() + .contains(&"id".to_string())); + assert!(!schema + .required + .as_ref() + .unwrap() + .contains(&"name".to_string())); } #[test] @@ -283,6 +263,7 @@ mod tests { } // Test struct with default and skip_serializing_if + // Required is determined solely by nullability (Option), not by defaults. #[test] fn test_parse_struct_to_schema_with_default_fields() { let struct_item: syn::ItemStruct = syn::parse_str( @@ -290,7 +271,7 @@ mod tests { struct Config { required_field: i32, #[serde(default)] - optional_with_default: String, + with_default: String, #[serde(skip_serializing_if = "Option::is_none")] maybe_skip: Option, } @@ -300,14 +281,14 @@ mod tests { let schema = parse_struct_to_schema(&struct_item, &HashSet::new(), &HashMap::new()); let props = schema.properties.as_ref().unwrap(); assert!(props.contains_key("required_field")); - assert!(props.contains_key("optional_with_default")); + assert!(props.contains_key("with_default")); assert!(props.contains_key("maybe_skip")); let required = schema.required.as_ref().unwrap(); assert!(required.contains(&"required_field".to_string())); - // Fields with default should NOT be required - assert!(!required.contains(&"optional_with_default".to_string())); - // Fields with skip_serializing_if should NOT be required + // Non-nullable fields are always required, even with #[serde(default)] + assert!(required.contains(&"with_default".to_string())); + // Option fields are not required (nullable) assert!(!required.contains(&"maybe_skip".to_string())); } diff --git a/crates/vespera_macro/src/schema_macro/mod.rs b/crates/vespera_macro/src/schema_macro/mod.rs index 0fcd420..c2dfba2 100644 --- a/crates/vespera_macro/src/schema_macro/mod.rs +++ b/crates/vespera_macro/src/schema_macro/mod.rs @@ -29,7 +29,7 @@ use proc_macro2::TokenStream; use quote::quote; use seaorm::{ RelationFieldInfo, convert_relation_type_to_schema_with_info, convert_type_with_chrono, - extract_sea_orm_default_value, is_sql_function_default, + extract_sea_orm_default_value, has_sea_orm_primary_key, is_sql_function_default, }; use transformation::{ build_omit_set, build_partial_config, build_pick_set, build_rename_map, determine_rename_all, @@ -408,8 +408,8 @@ pub fn generate_schema_type_code( // that may have ORM-specific attributes we don't want in the generated struct let serde_field_attrs = extract_field_serde_attrs(&field.attrs); - // Generate serde default + schema(default) from sea_orm(default_value) - // Only for non-partial, non-Option fields with literal (non-SQL-function) defaults + // Generate serde default + schema(default) from sea_orm(default_value) or primary_key + // Handles literal defaults, SQL function defaults, and implicit auto-increment let (serde_default_attr, schema_default_attr) = generate_sea_orm_default_attrs( &field.attrs, new_type_name, @@ -622,7 +622,7 @@ pub fn generate_schema_type_code( } /// Generate `#[serde(default = "...")]` and `#[schema(default = "...")]` attributes -/// from `#[sea_orm(default_value = ...)]` on source fields. +/// from `#[sea_orm(default_value = ...)]` or `#[sea_orm(primary_key)]` on source fields. /// /// Returns `(serde_default_attr, schema_default_attr)` as `TokenStream`s. /// - `serde_default_attr`: `#[serde(default = "default_structname_field")]` for deserialization @@ -630,12 +630,18 @@ pub fn generate_schema_type_code( /// /// Also generates a companion default function and appends it to `default_functions`. /// +/// Handles three categories of defaults: +/// 1. **Literal defaults** (`default_value = "42"`, `"draft"`, `0.7`): +/// Generates parse-based default function + schema default. +/// 2. **SQL function defaults** (`default_value = "NOW()"`, `"gen_random_uuid()"`): +/// Generates type-specific default function + schema default with type's zero value. +/// 3. **Primary key** (implicit auto-increment): +/// Treated as having an implicit default — generates type-specific default. +/// /// Skips serde default generation when: -/// - The field type doesn't implement `FromStr` (enums, custom types) -/// - The field already has `#[serde(default)]` /// - The field is wrapped in `Option` (partial mode or already optional) -/// -/// Always generates `#[schema(default)]` for OpenAPI when a literal default exists. +/// - The field already has `#[serde(default)]` +/// - For literal defaults: the field type doesn't implement `FromStr` fn generate_sea_orm_default_attrs( original_attrs: &[syn::Attribute], struct_name: &syn::Ident, @@ -650,46 +656,129 @@ fn generate_sea_orm_default_attrs( return (quote! {}, quote! {}); } - // Check for sea_orm(default_value) - let Some(default_value) = extract_sea_orm_default_value(original_attrs) else { - return (quote! {}, quote! {}); - }; + // Check for sea_orm(default_value) and sea_orm(primary_key) + let default_value = extract_sea_orm_default_value(original_attrs); + let has_pk = has_sea_orm_primary_key(original_attrs); - // SQL functions like NOW(), CURRENT_TIMESTAMP(), gen_random_uuid() - // can't be expressed as concrete JSON defaults — skip entirely. - if is_sql_function_default(&default_value) { + // No default source found + if default_value.is_none() && !has_pk { return (quote! {}, quote! {}); } - // Generate #[schema(default = "value")] for OpenAPI (always, regardless of type support) - let schema_default_attr = quote! { #[schema(default = #default_value)] }; - - // Check if field already has serde(default) let has_existing_serde_default = extract_default(original_attrs).is_some(); - if has_existing_serde_default { - return (quote! {}, schema_default_attr); - } - // Only generate serde default function for types known to implement FromStr - if !is_parseable_type(original_ty) { - return (quote! {}, schema_default_attr); - } + match &default_value { + // Literal default (e.g., "42", "draft", "0.7") + Some(value) if !is_sql_function_default(value) => { + let schema_default_attr = quote! { #[schema(default = #value)] }; + + if has_existing_serde_default { + return (quote! {}, schema_default_attr); + } - // Generate default function with struct-specific name to avoid collisions: - // fn default_{StructName}_{field_name}() -> Type { "value".parse().unwrap() } - let fn_name = format!("default_{struct_name}_{field_name}"); - let fn_ident = syn::Ident::new(&fn_name, proc_macro2::Span::call_site()); + if !is_parseable_type(original_ty) { + return (quote! {}, schema_default_attr); + } + + let fn_name = format!("default_{struct_name}_{field_name}"); + let fn_ident = syn::Ident::new(&fn_name, proc_macro2::Span::call_site()); + + default_functions.push(quote! { + #[allow(non_snake_case)] + fn #fn_ident() -> #field_ty { + #value.parse().unwrap() + } + }); - default_functions.push(quote! { - #[allow(non_snake_case)] - fn #fn_ident() -> #field_ty { - #default_value.parse().unwrap() + let serde_default_attr = quote! { #[serde(default = #fn_name)] }; + (serde_default_attr, schema_default_attr) } - }); + // SQL function default (NOW(), gen_random_uuid(), etc.) or primary_key auto-increment + _ => { + let Some((default_expr, schema_default_str)) = + sql_function_default_for_type(original_ty) + else { + return (quote! {}, quote! {}); + }; + + let schema_default_attr = quote! { #[schema(default = #schema_default_str)] }; + + if has_existing_serde_default { + return (quote! {}, schema_default_attr); + } + + let fn_name = format!("default_{struct_name}_{field_name}"); + let fn_ident = syn::Ident::new(&fn_name, proc_macro2::Span::call_site()); - let serde_default_attr = quote! { #[serde(default = #fn_name)] }; + default_functions.push(quote! { + #[allow(non_snake_case)] + fn #fn_ident() -> #field_ty { + #default_expr + } + }); + + let serde_default_attr = quote! { #[serde(default = #fn_name)] }; + (serde_default_attr, schema_default_attr) + } + } +} - (serde_default_attr, schema_default_attr) +/// Return a type-appropriate (Rust default expression, OpenAPI default string) pair +/// for fields with SQL function defaults or implicit auto-increment. +/// +/// The Rust expression is used in the generated `#[serde(default = "fn")]` function body. +/// The OpenAPI string is used in `#[schema(default = "value")]`. +fn sql_function_default_for_type(original_ty: &syn::Type) -> Option<(TokenStream, String)> { + let syn::Type::Path(type_path) = original_ty else { + return None; + }; + let segment = type_path.path.segments.last()?; + let type_name = segment.ident.to_string(); + + match type_name.as_str() { + "DateTimeWithTimeZone" | "DateTimeUtc" => { + let expr = quote! { + vespera::chrono::DateTime::::UNIX_EPOCH.fixed_offset() + }; + Some((expr, "1970-01-01T00:00:00+00:00".to_string())) + } + "DateTime" => { + // Could be chrono::DateTime — use UTC epoch + let expr = quote! { + vespera::chrono::DateTime::::UNIX_EPOCH.fixed_offset() + }; + Some((expr, "1970-01-01T00:00:00+00:00".to_string())) + } + "NaiveDateTime" => { + let expr = quote! { + vespera::chrono::NaiveDateTime::UNIX_EPOCH + }; + Some((expr, "1970-01-01T00:00:00".to_string())) + } + "NaiveDate" => { + let expr = quote! { + vespera::chrono::NaiveDate::default() + }; + Some((expr, "1970-01-01".to_string())) + } + "NaiveTime" | "Time" => { + let expr = quote! { + vespera::chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap() + }; + Some((expr, "00:00:00".to_string())) + } + "Uuid" => Some(( + quote! { Default::default() }, + "00000000-0000-0000-0000-000000000000".to_string(), + )), + "i8" | "i16" | "i32" | "i64" | "i128" | "isize" | "u8" | "u16" | "u32" | "u64" + | "u128" | "usize" | "f32" | "f64" | "Decimal" => { + Some((quote! { Default::default() }, "0".to_string())) + } + "bool" => Some((quote! { Default::default() }, "false".to_string())), + "String" => Some((quote! { Default::default() }, String::new())), + _ => None, + } } /// Check if a type is known to implement `FromStr` and can use `.parse().unwrap()`. diff --git a/crates/vespera_macro/src/schema_macro/seaorm.rs b/crates/vespera_macro/src/schema_macro/seaorm.rs index 7b6d556..c365189 100644 --- a/crates/vespera_macro/src/schema_macro/seaorm.rs +++ b/crates/vespera_macro/src/schema_macro/seaorm.rs @@ -278,6 +278,26 @@ pub fn is_sql_function_default(value: &str) -> bool { value.contains('(') } +/// Check if a field has `#[sea_orm(primary_key)]`. +/// +/// Primary keys in SeaORM imply auto-increment by default, +/// meaning the database provides a value even when the client omits it. +pub fn has_sea_orm_primary_key(attrs: &[syn::Attribute]) -> bool { + for attr in attrs { + if !attr.path().is_ident("sea_orm") { + continue; + } + let syn::Meta::List(meta_list) = &attr.meta else { + continue; + }; + let tokens = meta_list.tokens.to_string(); + if tokens.contains("primary_key") { + return true; + } + } + false +} + /// Check if a field in the struct is optional (Option). pub fn is_field_optional_in_struct(struct_item: &syn::ItemStruct, field_name: &str) -> bool { if let syn::Fields::Named(fields_named) = &struct_item.fields { @@ -1214,4 +1234,39 @@ mod tests { fn test_is_sql_function_default(#[case] value: &str, #[case] expected: bool) { assert_eq!(is_sql_function_default(value), expected); } + + // ========================================================================= + // Tests for has_sea_orm_primary_key + // ========================================================================= + + #[test] + fn test_has_sea_orm_primary_key_true() { + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(primary_key)])]; + assert!(has_sea_orm_primary_key(&attrs)); + } + + #[test] + fn test_has_sea_orm_primary_key_with_other_attrs() { + let attrs: Vec = + vec![syn::parse_quote!(#[sea_orm(primary_key, default_value = "gen_random_uuid()")])]; + assert!(has_sea_orm_primary_key(&attrs)); + } + + #[test] + fn test_has_sea_orm_primary_key_false() { + let attrs: Vec = + vec![syn::parse_quote!(#[sea_orm(default_value = "NOW()")])]; + assert!(!has_sea_orm_primary_key(&attrs)); + } + + #[test] + fn test_has_sea_orm_primary_key_no_sea_orm_attr() { + let attrs: Vec = vec![syn::parse_quote!(#[serde(default)])]; + assert!(!has_sea_orm_primary_key(&attrs)); + } + + #[test] + fn test_has_sea_orm_primary_key_empty_attrs() { + assert!(!has_sea_orm_primary_key(&[])); + } } diff --git a/crates/vespera_macro/src/schema_macro/tests.rs b/crates/vespera_macro/src/schema_macro/tests.rs index ffdf565..bc5f9e7 100644 --- a/crates/vespera_macro/src/schema_macro/tests.rs +++ b/crates/vespera_macro/src/schema_macro/tests.rs @@ -299,22 +299,44 @@ fn test_sea_orm_default_attrs_optional_field_skips() { } #[test] -fn test_sea_orm_default_attrs_no_default_value() { +fn test_sea_orm_default_attrs_no_default_and_no_pk() { + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(unique)])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("String").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = + generate_sea_orm_default_attrs(&attrs, &struct_name, "email", &ty, &ty, false, &mut fns); + assert!(serde.is_empty()); + assert!(schema.is_empty()); + assert!(fns.is_empty()); +} + +#[test] +fn test_sea_orm_default_attrs_primary_key_generates_defaults() { let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(primary_key)])]; let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); let ty: syn::Type = syn::parse_str("i32").unwrap(); let mut fns = Vec::new(); let (serde, schema) = generate_sea_orm_default_attrs(&attrs, &struct_name, "id", &ty, &ty, false, &mut fns); - assert!(serde.is_empty()); - assert!(schema.is_empty()); + let serde_str = serde.to_string(); + assert!( + serde_str.contains("serde"), + "primary_key should generate serde default: {serde_str}" + ); + let schema_str = schema.to_string(); + assert!( + schema_str.contains('0'), + "primary_key i32 should have schema default 0: {schema_str}" + ); + assert_eq!(fns.len(), 1, "should generate a default function"); } #[test] -fn test_sea_orm_default_attrs_sql_function_skips() { +fn test_sea_orm_default_attrs_sql_function_generates_defaults() { let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "NOW()")])]; let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); - let ty: syn::Type = syn::parse_str("String").unwrap(); + let ty: syn::Type = syn::parse_str("DateTimeWithTimeZone").unwrap(); let mut fns = Vec::new(); let (serde, schema) = generate_sea_orm_default_attrs( &attrs, @@ -325,8 +347,53 @@ fn test_sea_orm_default_attrs_sql_function_skips() { false, &mut fns, ); - assert!(serde.is_empty()); - assert!(schema.is_empty()); + let serde_str = serde.to_string(); + assert!( + serde_str.contains("serde"), + "SQL function default should generate serde default: {serde_str}" + ); + let schema_str = schema.to_string(); + assert!( + schema_str.contains("1970-01-01"), + "DateTimeWithTimeZone should have epoch default: {schema_str}" + ); + assert_eq!(fns.len(), 1, "should generate a default function"); +} + +#[test] +fn test_sea_orm_default_attrs_sql_function_uuid() { + let attrs: Vec = + vec![syn::parse_quote!(#[sea_orm(primary_key, default_value = "gen_random_uuid()")])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("Uuid").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = + generate_sea_orm_default_attrs(&attrs, &struct_name, "id", &ty, &ty, false, &mut fns); + let serde_str = serde.to_string(); + assert!( + serde_str.contains("serde"), + "UUID SQL default should generate serde default: {serde_str}" + ); + let schema_str = schema.to_string(); + assert!( + schema_str.contains("00000000-0000-0000-0000-000000000000"), + "Uuid should have nil UUID default: {schema_str}" + ); + assert_eq!(fns.len(), 1); +} + +#[test] +fn test_sea_orm_default_attrs_sql_function_unknown_type_skips() { + let attrs: Vec = + vec![syn::parse_quote!(#[sea_orm(default_value = "SOME_FUNC()")])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("MyCustomType").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = + generate_sea_orm_default_attrs(&attrs, &struct_name, "field", &ty, &ty, false, &mut fns); + assert!(serde.is_empty(), "unknown type should skip serde default"); + assert!(schema.is_empty(), "unknown type should skip schema default"); + assert!(fns.is_empty()); } #[test] diff --git a/crates/vespera_macro/src/schema_macro/type_utils.rs b/crates/vespera_macro/src/schema_macro/type_utils.rs index 7173d40..c5bc60c 100644 --- a/crates/vespera_macro/src/schema_macro/type_utils.rs +++ b/crates/vespera_macro/src/schema_macro/type_utils.rs @@ -272,6 +272,18 @@ pub fn get_type_default(ty: &Type) -> Option { .unwrap_or_else(|| serde_json::Number::from(0)), )), "bool" => Some(serde_json::Value::Bool(false)), + "Uuid" => Some(serde_json::Value::String( + "00000000-0000-0000-0000-000000000000".to_string(), + )), + "DateTime" | "DateTimeWithTimeZone" | "DateTimeUtc" => Some( + serde_json::Value::String("1970-01-01T00:00:00+00:00".to_string()), + ), + "NaiveDateTime" => { + Some(serde_json::Value::String("1970-01-01T00:00:00".to_string())) + } + "NaiveDate" => Some(serde_json::Value::String("1970-01-01".to_string())), + "NaiveTime" | "Time" => Some(serde_json::Value::String("00:00:00".to_string())), + "Decimal" => Some(serde_json::Value::Number(serde_json::Number::from(0))), _ => None, } }), @@ -628,12 +640,10 @@ mod tests { let ty = empty_type_path(); let result = extract_type_name(&ty); assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("type path has no segments") - ); + assert!(result + .unwrap_err() + .to_string() + .contains("type path has no segments")); } #[test] diff --git a/examples/axum-example/openapi.json b/examples/axum-example/openapi.json index 5bc6072..31aed97 100644 --- a/examples/axum-example/openapi.json +++ b/examples/axum-example/openapi.json @@ -2315,7 +2315,8 @@ "properties": { "id": { "type": "integer", - "format": "int64" + "format": "int64", + "default": 0 }, "temperature": { "type": "number", @@ -2324,7 +2325,8 @@ } }, "required": [ - "id" + "id", + "temperature" ] }, "ContactFormRequest": { @@ -3024,11 +3026,13 @@ }, "createdAt": { "type": "string", - "format": "date-time" + "format": "date-time", + "default": "1970-01-01T00:00:00+00:00" }, "id": { "type": "integer", - "format": "int32" + "format": "int32", + "default": 0 }, "memo": { "$ref": "#/components/schemas/MemoSchema" @@ -3039,7 +3043,8 @@ }, "updatedAt": { "type": "string", - "format": "date-time" + "format": "date-time", + "default": "1970-01-01T00:00:00+00:00" }, "user": { "$ref": "#/components/schemas/UserSchema" @@ -3068,11 +3073,13 @@ }, "createdAt": { "type": "string", - "format": "date-time" + "format": "date-time", + "default": "1970-01-01T00:00:00+00:00" }, "id": { "type": "integer", - "format": "int32" + "format": "int32", + "default": 0 }, "status": { "$ref": "#/components/schemas/MemoStatus" @@ -3152,11 +3159,13 @@ }, "createdAt": { "type": "string", - "format": "date-time" + "format": "date-time", + "default": "1970-01-01T00:00:00+00:00" }, "id": { "type": "integer", - "format": "int32" + "format": "int32", + "default": 0 }, "status": { "$ref": "#/components/schemas/MemoStatus" @@ -3190,11 +3199,13 @@ }, "createdAt": { "type": "string", - "format": "date-time" + "format": "date-time", + "default": "1970-01-01T00:00:00+00:00" }, "id": { "type": "integer", - "format": "int32" + "format": "int32", + "default": 0 }, "status": { "$ref": "#/components/schemas/MemoStatus" @@ -3204,7 +3215,8 @@ }, "updatedAt": { "type": "string", - "format": "date-time" + "format": "date-time", + "default": "1970-01-01T00:00:00+00:00" }, "user": { "$ref": "#/components/schemas/UserSchema" @@ -3230,11 +3242,13 @@ "properties": { "created_at": { "type": "string", - "format": "date-time" + "format": "date-time", + "default": "1970-01-01T00:00:00+00:00" }, "id": { "type": "integer", - "format": "int32" + "format": "int32", + "default": 0 }, "user_id": { "type": "integer", @@ -3300,7 +3314,11 @@ "description": "Items per page", "default": 20 } - } + }, + "required": [ + "page", + "perPage" + ] }, "PatchFileUploadRequest": { "type": "object", @@ -3498,6 +3516,9 @@ }, "required": [ "name", + "email5", + "email6", + "num", "in_skip", "in_skip3" ] @@ -3853,7 +3874,10 @@ "type": "string", "description": "Sort order: \"asc\" or \"desc\"" } - } + }, + "required": [ + "sort" + ] }, { "$ref": "#/components/schemas/Pagination" @@ -3911,7 +3935,8 @@ "createdAt": { "type": "string", "format": "date-time", - "description": "Created at" + "description": "Created at", + "default": "1970-01-01T00:00:00+00:00" }, "email": { "type": "string", @@ -3920,7 +3945,8 @@ "id": { "type": "integer", "format": "int32", - "description": "User ID" + "description": "User ID", + "default": 0 }, "name": { "type": "string", @@ -3929,7 +3955,8 @@ "updatedAt": { "type": "string", "format": "date-time", - "description": "Updated at" + "description": "Updated at", + "default": "1970-01-01T00:00:00+00:00" } }, "required": [ @@ -3994,7 +4021,8 @@ "createdAt": { "type": "string", "format": "date-time", - "description": "Created at" + "description": "Created at", + "default": "1970-01-01T00:00:00+00:00" }, "externalRef": { "type": "string", @@ -4005,7 +4033,8 @@ "id": { "type": "string", "format": "uuid", - "description": "Item ID" + "description": "Item ID", + "default": "00000000-0000-0000-0000-000000000000" }, "name": { "type": "string", diff --git a/examples/axum-example/tests/snapshots/integration_test__openapi.snap b/examples/axum-example/tests/snapshots/integration_test__openapi.snap index 8c7e89e..1658111 100644 --- a/examples/axum-example/tests/snapshots/integration_test__openapi.snap +++ b/examples/axum-example/tests/snapshots/integration_test__openapi.snap @@ -2319,7 +2319,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "properties": { "id": { "type": "integer", - "format": "int64" + "format": "int64", + "default": 0 }, "temperature": { "type": "number", @@ -2328,7 +2329,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" } }, "required": [ - "id" + "id", + "temperature" ] }, "ContactFormRequest": { @@ -3028,11 +3030,13 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" }, "createdAt": { "type": "string", - "format": "date-time" + "format": "date-time", + "default": "1970-01-01T00:00:00+00:00" }, "id": { "type": "integer", - "format": "int32" + "format": "int32", + "default": 0 }, "memo": { "$ref": "#/components/schemas/MemoSchema" @@ -3043,7 +3047,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" }, "updatedAt": { "type": "string", - "format": "date-time" + "format": "date-time", + "default": "1970-01-01T00:00:00+00:00" }, "user": { "$ref": "#/components/schemas/UserSchema" @@ -3072,11 +3077,13 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" }, "createdAt": { "type": "string", - "format": "date-time" + "format": "date-time", + "default": "1970-01-01T00:00:00+00:00" }, "id": { "type": "integer", - "format": "int32" + "format": "int32", + "default": 0 }, "status": { "$ref": "#/components/schemas/MemoStatus" @@ -3156,11 +3163,13 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" }, "createdAt": { "type": "string", - "format": "date-time" + "format": "date-time", + "default": "1970-01-01T00:00:00+00:00" }, "id": { "type": "integer", - "format": "int32" + "format": "int32", + "default": 0 }, "status": { "$ref": "#/components/schemas/MemoStatus" @@ -3194,11 +3203,13 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" }, "createdAt": { "type": "string", - "format": "date-time" + "format": "date-time", + "default": "1970-01-01T00:00:00+00:00" }, "id": { "type": "integer", - "format": "int32" + "format": "int32", + "default": 0 }, "status": { "$ref": "#/components/schemas/MemoStatus" @@ -3208,7 +3219,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" }, "updatedAt": { "type": "string", - "format": "date-time" + "format": "date-time", + "default": "1970-01-01T00:00:00+00:00" }, "user": { "$ref": "#/components/schemas/UserSchema" @@ -3234,11 +3246,13 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "properties": { "created_at": { "type": "string", - "format": "date-time" + "format": "date-time", + "default": "1970-01-01T00:00:00+00:00" }, "id": { "type": "integer", - "format": "int32" + "format": "int32", + "default": 0 }, "user_id": { "type": "integer", @@ -3304,7 +3318,11 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "description": "Items per page", "default": 20 } - } + }, + "required": [ + "page", + "perPage" + ] }, "PatchFileUploadRequest": { "type": "object", @@ -3502,6 +3520,9 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" }, "required": [ "name", + "email5", + "email6", + "num", "in_skip", "in_skip3" ] @@ -3857,7 +3878,10 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "type": "string", "description": "Sort order: \"asc\" or \"desc\"" } - } + }, + "required": [ + "sort" + ] }, { "$ref": "#/components/schemas/Pagination" @@ -3915,7 +3939,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "createdAt": { "type": "string", "format": "date-time", - "description": "Created at" + "description": "Created at", + "default": "1970-01-01T00:00:00+00:00" }, "email": { "type": "string", @@ -3924,7 +3949,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "id": { "type": "integer", "format": "int32", - "description": "User ID" + "description": "User ID", + "default": 0 }, "name": { "type": "string", @@ -3933,7 +3959,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "updatedAt": { "type": "string", "format": "date-time", - "description": "Updated at" + "description": "Updated at", + "default": "1970-01-01T00:00:00+00:00" } }, "required": [ @@ -3998,7 +4025,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "createdAt": { "type": "string", "format": "date-time", - "description": "Created at" + "description": "Created at", + "default": "1970-01-01T00:00:00+00:00" }, "externalRef": { "type": "string", @@ -4009,7 +4037,8 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "id": { "type": "string", "format": "uuid", - "description": "Item ID" + "description": "Item ID", + "default": "00000000-0000-0000-0000-000000000000" }, "name": { "type": "string", diff --git a/openapi.json b/openapi.json index 5bc6072..31aed97 100644 --- a/openapi.json +++ b/openapi.json @@ -2315,7 +2315,8 @@ "properties": { "id": { "type": "integer", - "format": "int64" + "format": "int64", + "default": 0 }, "temperature": { "type": "number", @@ -2324,7 +2325,8 @@ } }, "required": [ - "id" + "id", + "temperature" ] }, "ContactFormRequest": { @@ -3024,11 +3026,13 @@ }, "createdAt": { "type": "string", - "format": "date-time" + "format": "date-time", + "default": "1970-01-01T00:00:00+00:00" }, "id": { "type": "integer", - "format": "int32" + "format": "int32", + "default": 0 }, "memo": { "$ref": "#/components/schemas/MemoSchema" @@ -3039,7 +3043,8 @@ }, "updatedAt": { "type": "string", - "format": "date-time" + "format": "date-time", + "default": "1970-01-01T00:00:00+00:00" }, "user": { "$ref": "#/components/schemas/UserSchema" @@ -3068,11 +3073,13 @@ }, "createdAt": { "type": "string", - "format": "date-time" + "format": "date-time", + "default": "1970-01-01T00:00:00+00:00" }, "id": { "type": "integer", - "format": "int32" + "format": "int32", + "default": 0 }, "status": { "$ref": "#/components/schemas/MemoStatus" @@ -3152,11 +3159,13 @@ }, "createdAt": { "type": "string", - "format": "date-time" + "format": "date-time", + "default": "1970-01-01T00:00:00+00:00" }, "id": { "type": "integer", - "format": "int32" + "format": "int32", + "default": 0 }, "status": { "$ref": "#/components/schemas/MemoStatus" @@ -3190,11 +3199,13 @@ }, "createdAt": { "type": "string", - "format": "date-time" + "format": "date-time", + "default": "1970-01-01T00:00:00+00:00" }, "id": { "type": "integer", - "format": "int32" + "format": "int32", + "default": 0 }, "status": { "$ref": "#/components/schemas/MemoStatus" @@ -3204,7 +3215,8 @@ }, "updatedAt": { "type": "string", - "format": "date-time" + "format": "date-time", + "default": "1970-01-01T00:00:00+00:00" }, "user": { "$ref": "#/components/schemas/UserSchema" @@ -3230,11 +3242,13 @@ "properties": { "created_at": { "type": "string", - "format": "date-time" + "format": "date-time", + "default": "1970-01-01T00:00:00+00:00" }, "id": { "type": "integer", - "format": "int32" + "format": "int32", + "default": 0 }, "user_id": { "type": "integer", @@ -3300,7 +3314,11 @@ "description": "Items per page", "default": 20 } - } + }, + "required": [ + "page", + "perPage" + ] }, "PatchFileUploadRequest": { "type": "object", @@ -3498,6 +3516,9 @@ }, "required": [ "name", + "email5", + "email6", + "num", "in_skip", "in_skip3" ] @@ -3853,7 +3874,10 @@ "type": "string", "description": "Sort order: \"asc\" or \"desc\"" } - } + }, + "required": [ + "sort" + ] }, { "$ref": "#/components/schemas/Pagination" @@ -3911,7 +3935,8 @@ "createdAt": { "type": "string", "format": "date-time", - "description": "Created at" + "description": "Created at", + "default": "1970-01-01T00:00:00+00:00" }, "email": { "type": "string", @@ -3920,7 +3945,8 @@ "id": { "type": "integer", "format": "int32", - "description": "User ID" + "description": "User ID", + "default": 0 }, "name": { "type": "string", @@ -3929,7 +3955,8 @@ "updatedAt": { "type": "string", "format": "date-time", - "description": "Updated at" + "description": "Updated at", + "default": "1970-01-01T00:00:00+00:00" } }, "required": [ @@ -3994,7 +4021,8 @@ "createdAt": { "type": "string", "format": "date-time", - "description": "Created at" + "description": "Created at", + "default": "1970-01-01T00:00:00+00:00" }, "externalRef": { "type": "string", @@ -4005,7 +4033,8 @@ "id": { "type": "string", "format": "uuid", - "description": "Item ID" + "description": "Item ID", + "default": "00000000-0000-0000-0000-000000000000" }, "name": { "type": "string", From 586c8dc1afea9c49dfb8888f5c75523d5cdbfd2e Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 19 Feb 2026 02:09:35 +0900 Subject: [PATCH 4/6] Implement omit_default --- .../changepack_log_sBCzXuxu_PPwqRXNIWHS6.json | 1 + README.md | 53 +++++++++++++++++++ SKILL.md | 21 +++++++- .../vespera_macro/src/schema_macro/input.rs | 45 +++++++++++++++- crates/vespera_macro/src/schema_macro/mod.rs | 8 +++ .../vespera_macro/src/schema_macro/tests.rs | 1 + examples/axum-example/openapi.json | 20 ++++--- .../axum-example/src/routes/uuid_items.rs | 22 ++------ .../snapshots/integration_test__openapi.snap | 20 ++++--- openapi.json | 20 ++++--- 10 files changed, 168 insertions(+), 43 deletions(-) create mode 100644 .changepacks/changepack_log_sBCzXuxu_PPwqRXNIWHS6.json diff --git a/.changepacks/changepack_log_sBCzXuxu_PPwqRXNIWHS6.json b/.changepacks/changepack_log_sBCzXuxu_PPwqRXNIWHS6.json new file mode 100644 index 0000000..832644f --- /dev/null +++ b/.changepacks/changepack_log_sBCzXuxu_PPwqRXNIWHS6.json @@ -0,0 +1 @@ +{"changes":{"Cargo.toml":"Patch"},"note":"Add omit_default to schema_type!","date":"2026-02-18T16:50:22.753492900Z"} \ No newline at end of file diff --git a/README.md b/README.md index dc88fd8..dc8ca95 100644 --- a/README.md +++ b/README.md @@ -363,6 +363,52 @@ schema_type!(UserDTO from User, rename_all = "camelCase"); // Available: "camelCase", "snake_case", "PascalCase", "SCREAMING_SNAKE_CASE", etc. ``` +### Omit Fields with Database Defaults (`omit_default`) + +Automatically omit fields that have database-level defaults — perfect for create DTOs where the database handles `id`, `created_at`, etc.: + +```rust +#[derive(DeriveEntityModel)] +#[sea_orm(table_name = "posts")] +pub struct Model { + #[sea_orm(primary_key)] // ← has default (auto-increment) + pub id: i32, + pub title: String, + pub content: String, + #[sea_orm(default_value = "NOW()")] // ← has default (SQL function) + pub created_at: DateTimeWithTimeZone, +} + +// Omits `id` (primary_key) and `created_at` (default_value) automatically +schema_type!(CreatePostRequest from crate::models::post::Model, omit_default); +// Generated struct only has: title, content +``` + +`omit_default` detects fields with: +- `#[sea_orm(primary_key)]` — auto-increment / generated IDs +- `#[sea_orm(default_value = "...")]` — SQL defaults like `NOW()`, `gen_random_uuid()`, literals + +Can be combined with other parameters: + +```rust +// omit_default + add extra fields +schema_type!(CreateItemRequest from Model, omit_default, add = [("tags": Vec)]); +``` + +### Database Defaults in OpenAPI + +Fields with database defaults automatically get `default` values in the generated OpenAPI schema: + +| SeaORM Attribute | OpenAPI Default | +|-----------------|-----------------| +| `primary_key` (Uuid) | `"00000000-0000-0000-0000-000000000000"` | +| `primary_key` (i32/i64) | `0` | +| `default_value = "NOW()"` | `"1970-01-01T00:00:00+00:00"` | +| `default_value = "gen_random_uuid()"` | `"00000000-0000-0000-0000-000000000000"` | +| `default_value = "true"` | `true` (literal passthrough) | + +> **Note:** `required` is determined solely by nullability (`Option`). Fields with defaults are still `required` unless they are `Option`. + ### SeaORM Integration `schema_type!` has first-class support for SeaORM models with relations: @@ -434,6 +480,7 @@ When `multipart` is enabled: | `rename_all` | Serde rename strategy: `rename_all = "camelCase"` | | `ignore` | Skip Schema derive (bare keyword, no value) | | `multipart` | Derive `TryFromMultipart` instead of serde (bare keyword) | +| `omit_default` | Auto-omit fields with DB defaults: `primary_key`, `default_value` (bare keyword) | --- @@ -547,6 +594,12 @@ This automatically: | `Vec` | `array` with items | | `Option` | nullable T | | `HashMap` | `object` with additionalProperties | +| `BTreeSet`, `HashSet` | `array` with `uniqueItems: true` | +| `Uuid` | `string` with `format: uuid` | +| `Decimal` | `string` with `format: decimal` | +| `NaiveDate` | `string` with `format: date` | +| `NaiveTime` | `string` with `format: time` | +| `DateTime`, `DateTimeWithTimeZone` | `string` with `format: date-time` | | `FieldData` | `string` with `format: binary` | | Custom struct | `$ref` to components/schemas | diff --git a/SKILL.md b/SKILL.md index 0d3bcf2..51c6c24 100644 --- a/SKILL.md +++ b/SKILL.md @@ -39,8 +39,14 @@ pub struct User { id: u32, name: String } | `f32`, `f64` | `number` | | | `bool` | `boolean` | | | `Vec` | `array` + items | | +| `BTreeSet`, `HashSet` | `array` + items + `uniqueItems: true` | Set types | | `Option` | T (nullable context) | Parent marks as optional | | `HashMap` | `object` + additionalProperties | | +| `Uuid` | `string` + `format: uuid` | | +| `Decimal` | `string` + `format: decimal` | | +| `NaiveDate` | `string` + `format: date` | | +| `NaiveTime` | `string` + `format: time` | | +| `DateTime`, `DateTimeWithTimeZone` | `string` + `format: date-time` | | | `FieldData` | `string` + `format: binary` | File upload field | | `()` | empty response | 204 No Content | | Custom struct | `$ref` | Must derive Schema | @@ -112,7 +118,7 @@ pub struct UserResponse { #[serde(rename = "fullName")] // ✅ Respected name: String, // → "fullName" in JSON Schema - #[serde(default)] // ✅ Marks as optional in schema + #[serde(default)] // ✅ Recognized (does NOT affect `required` — only Option does) bio: Option, #[serde(skip)] // ✅ Excluded from schema @@ -188,6 +194,7 @@ npx @apidevtools/swagger-cli validate openapi.json **Primary Parameters (USE THESE):** - `pick = [...]` - Allowlist: include ONLY these fields - `omit = [...]` - Denylist: exclude these fields +- `omit_default` - Auto-omit fields with DB defaults (primary_key, default_value) **Advanced Parameters (USE SPARINGLY):** - `partial` - For PATCH endpoints only @@ -232,6 +239,12 @@ 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"]); +// Auto-omit fields with DB defaults (primary_key, default_value = "...") +schema_type!(CreatePostRequest from crate::models::post::Model, omit_default); + +// Combine omit_default with add +schema_type!(CreateItemRequest from crate::models::item::Model, omit_default, add = [("tags": Vec)]); + // Custom serde rename strategy schema_type!(UserSnakeCase from crate::models::user::Model, rename_all = "snake_case"); @@ -322,6 +335,7 @@ Json(model.into()) // Easy conversion! |-----------|-------------|---------| | `pick` | Include only these fields | `pick = ["name", "email"]` | | `omit` | Exclude these fields | `omit = ["password"]` | +| `omit_default` | Auto-omit fields with DB defaults | `omit_default` (bare keyword) | **Situational (Use When Needed):** @@ -379,6 +393,10 @@ vespera::schema_type!(Schema from Model, name = "MemoSchema"); **Circular Reference Handling:** Automatically detected and handled by inlining fields. +**Database Defaults in OpenAPI:** Fields with `#[sea_orm(default_value = "...")]` or `#[sea_orm(primary_key)]` automatically get `default` values in the generated OpenAPI schema. SQL functions like `NOW()` and `gen_random_uuid()` are mapped to type-appropriate defaults. + +**Required Logic:** `required` is determined **solely by nullability** (`Option`). Fields with `#[serde(default)]` or `#[serde(skip_serializing_if)]` are still `required` unless they are `Option`. + ### Complete Example ```rust @@ -477,6 +495,7 @@ tempfile = "3" # For NamedTempFile file uploads ```rust // ✅ RECOMMENDED PATTERNS schema_type!(CreateUserRequest from crate::models::user::Model, pick = ["name", "email"]); +schema_type!(CreatePostRequest from crate::models::post::Model, omit_default); schema_type!(UserResponse from crate::models::user::Model, omit = ["password_hash"]); schema_type!(UserListItem from crate::models::user::Model, pick = ["id", "name"]); diff --git a/crates/vespera_macro/src/schema_macro/input.rs b/crates/vespera_macro/src/schema_macro/input.rs index 77f7017..1db178f 100644 --- a/crates/vespera_macro/src/schema_macro/input.rs +++ b/crates/vespera_macro/src/schema_macro/input.rs @@ -3,9 +3,10 @@ //! Defines input structures for `schema!` and `schema_type!` macros. use syn::{ - Ident, LitStr, Token, Type, bracketed, parenthesized, + bracketed, parenthesized, parse::{Parse, ParseStream}, punctuated::Punctuated, + Ident, LitStr, Token, Type, }; /// Input for the schema! macro @@ -90,6 +91,7 @@ impl Parse for SchemaInput { /// 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` +#[allow(clippy::struct_excessive_bools)] pub struct SchemaTypeInput { /// The new type name to generate pub new_type: Ident, @@ -123,6 +125,9 @@ pub struct SchemaTypeInput { /// Whether to generate a multipart/form-data struct (derives `TryFromMultipart` instead of serde) /// Use `multipart` bare keyword to set this to true. pub multipart: bool, + /// Whether to omit fields that have database defaults (sea_orm `default_value` or `primary_key`). + /// Use `omit_default` bare keyword to set this to true. + pub omit_default: bool, } /// Mode for the `partial` keyword in `schema_type`! @@ -204,6 +209,7 @@ impl Parse for SchemaTypeInput { let mut schema_name = None; let mut rename_all = None; let mut multipart = false; + let mut omit_default = false; // Parse optional parameters while input.peek(Token![,]) { @@ -293,11 +299,15 @@ impl Parse for SchemaTypeInput { // bare `multipart` - derive TryFromMultipart instead of serde multipart = true; } + "omit_default" => { + // bare `omit_default` - omit fields with database defaults + omit_default = true; + } _ => { return Err(syn::Error::new( ident.span(), format!( - "unknown parameter: `{ident_str}`. Expected `omit`, `pick`, `rename`, `add`, `clone`, `partial`, `ignore`, `name`, `rename_all`, or `multipart`" + "unknown parameter: `{ident_str}`. Expected `omit`, `pick`, `rename`, `add`, `clone`, `partial`, `ignore`, `name`, `rename_all`, `multipart`, or `omit_default`" ), )); } @@ -325,6 +335,7 @@ impl Parse for SchemaTypeInput { schema_name, rename_all, multipart, + omit_default, }) } } @@ -699,4 +710,34 @@ mod tests { assert!(input.multipart); assert!(matches!(input.partial, Some(PartialMode::All))); } + + #[test] + fn test_parse_schema_type_input_with_omit_default() { + let tokens = quote::quote!(CreateUser from Model, omit_default); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + assert!(input.omit_default); + } + + #[test] + fn test_parse_schema_type_input_with_omit_default_and_omit() { + let tokens = quote::quote!(CreateUser from Model, omit_default, omit = ["password"]); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + assert!(input.omit_default); + assert_eq!(input.omit.unwrap(), vec!["password"]); + } + + #[test] + fn test_parse_schema_type_input_with_omit_default_and_pick() { + let tokens = quote::quote!(CreateUser from Model, omit_default, pick = ["name", "email"]); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + assert!(input.omit_default); + assert_eq!(input.pick.unwrap(), vec!["name", "email"]); + } + + #[test] + fn test_parse_schema_type_input_omit_default_defaults_to_false() { + let tokens = quote::quote!(CreateUser from User); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + assert!(!input.omit_default); + } } diff --git a/crates/vespera_macro/src/schema_macro/mod.rs b/crates/vespera_macro/src/schema_macro/mod.rs index c2dfba2..4326db8 100644 --- a/crates/vespera_macro/src/schema_macro/mod.rs +++ b/crates/vespera_macro/src/schema_macro/mod.rs @@ -238,6 +238,14 @@ pub fn generate_schema_type_code( continue; } + // Apply omit_default: skip fields with sea_orm(default_value) or sea_orm(primary_key) + if input.omit_default + && (extract_sea_orm_default_value(&field.attrs).is_some() + || has_sea_orm_primary_key(&field.attrs)) + { + continue; + } + // Check if this is a SeaORM relation type let is_relation = is_seaorm_relation_type(&field.ty); diff --git a/crates/vespera_macro/src/schema_macro/tests.rs b/crates/vespera_macro/src/schema_macro/tests.rs index bc5f9e7..973221a 100644 --- a/crates/vespera_macro/src/schema_macro/tests.rs +++ b/crates/vespera_macro/src/schema_macro/tests.rs @@ -575,6 +575,7 @@ fn test_generate_schema_type_code_preserves_struct_doc() { ignore_schema: false, rename_all: None, multipart: false, + omit_default: false, }; let struct_def = StructMetadata { name: "User".to_string(), diff --git a/examples/axum-example/openapi.json b/examples/axum-example/openapi.json index 31aed97..2d7af37 100644 --- a/examples/axum-example/openapi.json +++ b/examples/axum-example/openapi.json @@ -2494,18 +2494,20 @@ }, "CreateUuidItemRequest": { "type": "object", + "description": "UUID item model for testing UUID format in OpenAPI", "properties": { - "external_ref": { + "externalRef": { "type": "string", "format": "uuid", + "description": "External reference UUID", "nullable": true }, "name": { - "type": "string" + "type": "string", + "description": "Item name" }, "tags": { "type": "array", - "description": "Unique tags for this item", "items": { "type": "string" }, @@ -3986,22 +3988,26 @@ }, "UuidItem": { "type": "object", + "description": "UUID item model for testing UUID format in OpenAPI", "properties": { - "external_ref": { + "externalRef": { "type": "string", "format": "uuid", + "description": "External reference UUID", "nullable": true }, "id": { "type": "string", - "format": "uuid" + "format": "uuid", + "description": "Item ID", + "default": "00000000-0000-0000-0000-000000000000" }, "name": { - "type": "string" + "type": "string", + "description": "Item name" }, "tags": { "type": "array", - "description": "Unique tags for this item", "items": { "type": "string" }, diff --git a/examples/axum-example/src/routes/uuid_items.rs b/examples/axum-example/src/routes/uuid_items.rs index 0b09f24..9240968 100644 --- a/examples/axum-example/src/routes/uuid_items.rs +++ b/examples/axum-example/src/routes/uuid_items.rs @@ -1,26 +1,10 @@ use std::collections::BTreeSet; -use serde::{Deserialize, Serialize}; use uuid::Uuid; -use vespera::Schema; -use vespera::axum::Json; +use vespera::{axum::Json, schema_type}; -#[derive(Serialize, Deserialize, Schema)] -pub struct UuidItem { - pub id: Uuid, - pub name: String, - pub external_ref: Option, - /// Unique tags for this item - pub tags: BTreeSet, -} - -#[derive(Deserialize, Schema)] -pub struct CreateUuidItemRequest { - pub name: String, - pub external_ref: Option, - /// Unique tags for this item - pub tags: BTreeSet, -} +schema_type!(UuidItem from crate::models::uuid_item::Model, omit = ["created_at"], add = [("tags": BTreeSet)]); +schema_type!(CreateUuidItemRequest from crate::models::uuid_item::Model, omit_default, add = [("tags": BTreeSet)]); /// List all UUID items #[vespera::route(get, tags = ["uuid_items"])] diff --git a/examples/axum-example/tests/snapshots/integration_test__openapi.snap b/examples/axum-example/tests/snapshots/integration_test__openapi.snap index 1658111..603f36f 100644 --- a/examples/axum-example/tests/snapshots/integration_test__openapi.snap +++ b/examples/axum-example/tests/snapshots/integration_test__openapi.snap @@ -2498,18 +2498,20 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" }, "CreateUuidItemRequest": { "type": "object", + "description": "UUID item model for testing UUID format in OpenAPI", "properties": { - "external_ref": { + "externalRef": { "type": "string", "format": "uuid", + "description": "External reference UUID", "nullable": true }, "name": { - "type": "string" + "type": "string", + "description": "Item name" }, "tags": { "type": "array", - "description": "Unique tags for this item", "items": { "type": "string" }, @@ -3990,22 +3992,26 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" }, "UuidItem": { "type": "object", + "description": "UUID item model for testing UUID format in OpenAPI", "properties": { - "external_ref": { + "externalRef": { "type": "string", "format": "uuid", + "description": "External reference UUID", "nullable": true }, "id": { "type": "string", - "format": "uuid" + "format": "uuid", + "description": "Item ID", + "default": "00000000-0000-0000-0000-000000000000" }, "name": { - "type": "string" + "type": "string", + "description": "Item name" }, "tags": { "type": "array", - "description": "Unique tags for this item", "items": { "type": "string" }, diff --git a/openapi.json b/openapi.json index 31aed97..2d7af37 100644 --- a/openapi.json +++ b/openapi.json @@ -2494,18 +2494,20 @@ }, "CreateUuidItemRequest": { "type": "object", + "description": "UUID item model for testing UUID format in OpenAPI", "properties": { - "external_ref": { + "externalRef": { "type": "string", "format": "uuid", + "description": "External reference UUID", "nullable": true }, "name": { - "type": "string" + "type": "string", + "description": "Item name" }, "tags": { "type": "array", - "description": "Unique tags for this item", "items": { "type": "string" }, @@ -3986,22 +3988,26 @@ }, "UuidItem": { "type": "object", + "description": "UUID item model for testing UUID format in OpenAPI", "properties": { - "external_ref": { + "externalRef": { "type": "string", "format": "uuid", + "description": "External reference UUID", "nullable": true }, "id": { "type": "string", - "format": "uuid" + "format": "uuid", + "description": "Item ID", + "default": "00000000-0000-0000-0000-000000000000" }, "name": { - "type": "string" + "type": "string", + "description": "Item name" }, "tags": { "type": "array", - "description": "Unique tags for this item", "items": { "type": "string" }, From 3f318d8a231a660b0ae2fc95a16268bbcc7ce4cd Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 19 Feb 2026 02:10:38 +0900 Subject: [PATCH 5/6] Fix lint --- .../src/parser/schema/struct_schema.rs | 24 +++++++++++-------- .../src/parser/schema/type_schema.rs | 3 ++- .../vespera_macro/src/schema_macro/input.rs | 3 +-- crates/vespera_macro/src/schema_macro/mod.rs | 4 ++-- .../src/schema_macro/type_utils.rs | 10 ++++---- 5 files changed, 25 insertions(+), 19 deletions(-) diff --git a/crates/vespera_macro/src/parser/schema/struct_schema.rs b/crates/vespera_macro/src/parser/schema/struct_schema.rs index 4097f47..cc2db87 100644 --- a/crates/vespera_macro/src/parser/schema/struct_schema.rs +++ b/crates/vespera_macro/src/parser/schema/struct_schema.rs @@ -196,16 +196,20 @@ mod tests { let props = schema.properties.as_ref().unwrap(); assert!(props.contains_key("id")); assert!(props.contains_key("name")); - assert!(schema - .required - .as_ref() - .unwrap() - .contains(&"id".to_string())); - assert!(!schema - .required - .as_ref() - .unwrap() - .contains(&"name".to_string())); + assert!( + schema + .required + .as_ref() + .unwrap() + .contains(&"id".to_string()) + ); + assert!( + !schema + .required + .as_ref() + .unwrap() + .contains(&"name".to_string()) + ); } #[test] diff --git a/crates/vespera_macro/src/parser/schema/type_schema.rs b/crates/vespera_macro/src/parser/schema/type_schema.rs index 1878c17..d1660f1 100644 --- a/crates/vespera_macro/src/parser/schema/type_schema.rs +++ b/crates/vespera_macro/src/parser/schema/type_schema.rs @@ -327,7 +327,8 @@ fn parse_type_impl( })), // Standard library types that should not be referenced // Note: HashMap and BTreeMap are handled above in generic types - "Vec" | "HashSet" | "BTreeSet" | "Option" | "Result" | "Json" | "Path" | "Query" | "Header" => { + "Vec" | "HashSet" | "BTreeSet" | "Option" | "Result" | "Json" | "Path" + | "Query" | "Header" => { // These are not schema types, return object schema SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object))) } diff --git a/crates/vespera_macro/src/schema_macro/input.rs b/crates/vespera_macro/src/schema_macro/input.rs index 1db178f..4720bd5 100644 --- a/crates/vespera_macro/src/schema_macro/input.rs +++ b/crates/vespera_macro/src/schema_macro/input.rs @@ -3,10 +3,9 @@ //! Defines input structures for `schema!` and `schema_type!` macros. use syn::{ - bracketed, parenthesized, + Ident, LitStr, Token, Type, bracketed, parenthesized, parse::{Parse, ParseStream}, punctuated::Punctuated, - Ident, LitStr, Token, Type, }; /// Input for the schema! macro diff --git a/crates/vespera_macro/src/schema_macro/mod.rs b/crates/vespera_macro/src/schema_macro/mod.rs index 4326db8..8271e86 100644 --- a/crates/vespera_macro/src/schema_macro/mod.rs +++ b/crates/vespera_macro/src/schema_macro/mod.rs @@ -779,8 +779,8 @@ fn sql_function_default_for_type(original_ty: &syn::Type) -> Option<(TokenStream quote! { Default::default() }, "00000000-0000-0000-0000-000000000000".to_string(), )), - "i8" | "i16" | "i32" | "i64" | "i128" | "isize" | "u8" | "u16" | "u32" | "u64" - | "u128" | "usize" | "f32" | "f64" | "Decimal" => { + "i8" | "i16" | "i32" | "i64" | "i128" | "isize" | "u8" | "u16" | "u32" | "u64" | "u128" + | "usize" | "f32" | "f64" | "Decimal" => { Some((quote! { Default::default() }, "0".to_string())) } "bool" => Some((quote! { Default::default() }, "false".to_string())), diff --git a/crates/vespera_macro/src/schema_macro/type_utils.rs b/crates/vespera_macro/src/schema_macro/type_utils.rs index c5bc60c..dd425fc 100644 --- a/crates/vespera_macro/src/schema_macro/type_utils.rs +++ b/crates/vespera_macro/src/schema_macro/type_utils.rs @@ -640,10 +640,12 @@ mod tests { let ty = empty_type_path(); let result = extract_type_name(&ty); assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("type path has no segments")); + assert!( + result + .unwrap_err() + .to_string() + .contains("type path has no segments") + ); } #[test] From 0a5edacd7e4ffa35984e6a32ab615c184f8cfb40 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 19 Feb 2026 02:32:19 +0900 Subject: [PATCH 6/6] Add testcase --- .../vespera_macro/src/schema_macro/seaorm.rs | 7 + .../vespera_macro/src/schema_macro/tests.rs | 239 ++++++++++++++++++ .../src/schema_macro/type_utils.rs | 3 +- 3 files changed, 247 insertions(+), 2 deletions(-) diff --git a/crates/vespera_macro/src/schema_macro/seaorm.rs b/crates/vespera_macro/src/schema_macro/seaorm.rs index c365189..c6ec772 100644 --- a/crates/vespera_macro/src/schema_macro/seaorm.rs +++ b/crates/vespera_macro/src/schema_macro/seaorm.rs @@ -1269,4 +1269,11 @@ mod tests { fn test_has_sea_orm_primary_key_empty_attrs() { assert!(!has_sea_orm_primary_key(&[])); } + + #[test] + fn test_has_sea_orm_primary_key_non_list_meta() { + // #[sea_orm = "value"] is a NameValue meta, not a List — should be skipped + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm = "something"])]; + assert!(!has_sea_orm_primary_key(&attrs)); + } } diff --git a/crates/vespera_macro/src/schema_macro/tests.rs b/crates/vespera_macro/src/schema_macro/tests.rs index 973221a..0547014 100644 --- a/crates/vespera_macro/src/schema_macro/tests.rs +++ b/crates/vespera_macro/src/schema_macro/tests.rs @@ -510,6 +510,245 @@ fn test_generate_schema_type_code_with_partial_fields() { ); } +// ============================================================ +// Coverage: omit_default in generate_schema_type_code (line 180) +// ============================================================ + +#[test] +fn test_generate_schema_type_code_with_omit_default() { + let storage = to_storage(vec![create_test_struct_metadata( + "Model", + r#"#[sea_orm(table_name = "items")] + pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub name: String, + #[sea_orm(default_value = "NOW()")] + pub created_at: DateTimeWithTimeZone, + }"#, + )]); + + let tokens = quote!(CreateItemRequest from Model, omit_default); + let input: SchemaTypeInput = syn::parse2(tokens).unwrap(); + let result = generate_schema_type_code(&input, &storage); + + assert!(result.is_ok()); + let (tokens, _metadata) = result.unwrap(); + let output = tokens.to_string(); + // id (primary_key) and created_at (default_value) should be omitted + assert!( + !output.contains("id :"), + "id should be omitted by omit_default: {output}" + ); + assert!( + !output.contains("created_at"), + "created_at should be omitted by omit_default: {output}" + ); + // name should remain + assert!(output.contains("name"), "name should remain: {output}"); +} + +// ============================================================ +// Coverage: SQL function default with existing serde default (line 554) +// ============================================================ + +#[test] +fn test_sea_orm_default_attrs_sql_function_with_existing_serde_default() { + let attrs: Vec = vec![ + syn::parse_quote!(#[sea_orm(default_value = "NOW()")]), + syn::parse_quote!(#[serde(default)]), + ]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("DateTimeWithTimeZone").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = generate_sea_orm_default_attrs( + &attrs, + &struct_name, + "created_at", + &ty, + &ty, + false, + &mut fns, + ); + // serde attr should be empty (already has serde default) + assert!(serde.is_empty()); + // schema attr should still be generated + let schema_str = schema.to_string(); + assert!( + schema_str.contains("schema"), + "should have schema attr: {schema_str}" + ); + assert!( + schema_str.contains("1970-01-01"), + "should have epoch default: {schema_str}" + ); + assert!( + fns.is_empty(), + "no default fn needed when serde(default) exists" + ); +} + +// ============================================================ +// Coverage: sql_function_default_for_type branches (lines 580-615) +// ============================================================ + +#[test] +fn test_sea_orm_default_attrs_sql_function_non_path_type() { + // Non-Path type (reference) triggers early return None in sql_function_default_for_type + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "NOW()")])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("&str").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = + generate_sea_orm_default_attrs(&attrs, &struct_name, "field", &ty, &ty, false, &mut fns); + assert!(serde.is_empty(), "non-Path type should skip serde default"); + assert!( + schema.is_empty(), + "non-Path type should skip schema default" + ); + assert!(fns.is_empty()); +} + +#[test] +fn test_sea_orm_default_attrs_sql_function_datetime() { + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "NOW()")])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("DateTime").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = generate_sea_orm_default_attrs( + &attrs, + &struct_name, + "created_at", + &ty, + &ty, + false, + &mut fns, + ); + let serde_str = serde.to_string(); + assert!( + serde_str.contains("serde"), + "DateTime should generate serde default: {serde_str}" + ); + let schema_str = schema.to_string(); + assert!( + schema_str.contains("1970-01-01T00:00:00+00:00"), + "DateTime should have epoch default: {schema_str}" + ); + assert_eq!(fns.len(), 1); +} + +#[test] +fn test_sea_orm_default_attrs_sql_function_naive_datetime() { + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "NOW()")])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("NaiveDateTime").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = generate_sea_orm_default_attrs( + &attrs, + &struct_name, + "created_at", + &ty, + &ty, + false, + &mut fns, + ); + let serde_str = serde.to_string(); + assert!( + serde_str.contains("serde"), + "NaiveDateTime should generate serde default: {serde_str}" + ); + let schema_str = schema.to_string(); + assert!( + schema_str.contains("1970-01-01T00:00:00"), + "NaiveDateTime should have epoch default: {schema_str}" + ); + assert_eq!(fns.len(), 1); +} + +#[test] +fn test_sea_orm_default_attrs_sql_function_naive_date() { + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "NOW()")])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("NaiveDate").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = generate_sea_orm_default_attrs( + &attrs, + &struct_name, + "date_field", + &ty, + &ty, + false, + &mut fns, + ); + let serde_str = serde.to_string(); + assert!( + serde_str.contains("serde"), + "NaiveDate should generate serde default: {serde_str}" + ); + let schema_str = schema.to_string(); + assert!( + schema_str.contains("1970-01-01"), + "NaiveDate should have date default: {schema_str}" + ); + assert_eq!(fns.len(), 1); +} + +#[test] +fn test_sea_orm_default_attrs_sql_function_naive_time() { + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "NOW()")])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("NaiveTime").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = generate_sea_orm_default_attrs( + &attrs, + &struct_name, + "time_field", + &ty, + &ty, + false, + &mut fns, + ); + let serde_str = serde.to_string(); + assert!( + serde_str.contains("serde"), + "NaiveTime should generate serde default: {serde_str}" + ); + let schema_str = schema.to_string(); + assert!( + schema_str.contains("00:00:00"), + "NaiveTime should have time default: {schema_str}" + ); + assert_eq!(fns.len(), 1); +} + +#[test] +fn test_sea_orm_default_attrs_sql_function_time_type() { + let attrs: Vec = vec![syn::parse_quote!(#[sea_orm(default_value = "NOW()")])]; + let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str("Time").unwrap(); + let mut fns = Vec::new(); + let (serde, schema) = generate_sea_orm_default_attrs( + &attrs, + &struct_name, + "time_field", + &ty, + &ty, + false, + &mut fns, + ); + let serde_str = serde.to_string(); + assert!( + serde_str.contains("serde"), + "Time should generate serde default: {serde_str}" + ); + let schema_str = schema.to_string(); + assert!( + schema_str.contains("00:00:00"), + "Time should have time default: {schema_str}" + ); + assert_eq!(fns.len(), 1); +} + // --- Coverage: is_parseable_type empty segments --- #[test] diff --git a/crates/vespera_macro/src/schema_macro/type_utils.rs b/crates/vespera_macro/src/schema_macro/type_utils.rs index dd425fc..4a7c9ee 100644 --- a/crates/vespera_macro/src/schema_macro/type_utils.rs +++ b/crates/vespera_macro/src/schema_macro/type_utils.rs @@ -264,7 +264,7 @@ pub fn get_type_default(ty: &Type) -> Option { Type::Path(type_path) => type_path.path.segments.last().and_then(|segment| { match segment.ident.to_string().as_str() { "String" => Some(serde_json::Value::String(String::new())), - "i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" => { + "i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" | "Decimal" => { Some(serde_json::Value::Number(serde_json::Number::from(0))) } "f32" | "f64" => Some(serde_json::Value::Number( @@ -283,7 +283,6 @@ pub fn get_type_default(ty: &Type) -> Option { } "NaiveDate" => Some(serde_json::Value::String("1970-01-01".to_string())), "NaiveTime" | "Time" => Some(serde_json::Value::String("00:00:00".to_string())), - "Decimal" => Some(serde_json::Value::Number(serde_json::Number::from(0))), _ => None, } }),