Skip to content

Commit 335b71f

Browse files
committed
schema serde
1 parent f2b7c95 commit 335b71f

4 files changed

Lines changed: 196 additions & 13 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"changes":{"crates/vespera_core/Cargo.toml":"Patch","crates/vespera_macro/Cargo.toml":"Patch","crates/vespera/Cargo.toml":"Patch"},"note":"Remain serde by schema_type macro","date":"2026-01-30T09:35:47.612796500Z"}

Cargo.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/vespera_macro/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,8 @@ pub fn schema(input: TokenStream) -> TokenStream {
171171
/// - `pick = [...]`: List of field names to include (excludes all others)
172172
/// - `omit = [...]`: List of field names to exclude
173173
/// - `clone = bool`: Whether to derive Clone (default: true)
174+
/// - `partial`: Make all fields `Option<T>` (fields already `Option<T>` are unchanged)
175+
/// - `partial = [...]`: Make only listed fields `Option<T>`
174176
///
175177
/// Note: `omit` and `pick` cannot be used together.
176178
///

crates/vespera_macro/src/schema_macro.rs

Lines changed: 190 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use quote::quote;
99
use std::collections::HashSet;
1010
use std::path::Path;
1111
use syn::punctuated::Punctuated;
12-
use syn::{Ident, LitStr, Token, Type, bracketed, parenthesized, parse::Parse, parse::ParseStream};
12+
use syn::{bracketed, parenthesized, parse::Parse, parse::ParseStream, Ident, LitStr, Token, Type};
1313

1414
use crate::metadata::StructMetadata;
1515
use crate::parser::{
@@ -469,6 +469,20 @@ pub struct SchemaTypeInput {
469469
pub add: Option<Vec<(String, Type)>>,
470470
/// Whether to derive Clone (default: true)
471471
pub derive_clone: bool,
472+
/// Fields to wrap in Option<T> for partial updates.
473+
/// - `partial` (bare) = all fields become Option<T>
474+
/// - `partial = ["field1", "field2"]` = only listed fields become Option<T>
475+
/// Fields already Option<T> are left unchanged.
476+
pub partial: Option<PartialMode>,
477+
}
478+
479+
/// Mode for the `partial` keyword in schema_type!
480+
#[derive(Clone, Debug)]
481+
pub enum PartialMode {
482+
/// All fields become Option<T>
483+
All,
484+
/// Only listed fields become Option<T>
485+
Fields(Vec<String>),
472486
}
473487

474488
/// Helper struct to parse an add field: ("field_name": Type)
@@ -533,6 +547,7 @@ impl Parse for SchemaTypeInput {
533547
let mut rename = None;
534548
let mut add = None;
535549
let mut derive_clone = true;
550+
let mut partial = None;
536551

537552
// Parse optional parameters
538553
while input.peek(Token![,]) {
@@ -583,11 +598,27 @@ impl Parse for SchemaTypeInput {
583598
let value: syn::LitBool = input.parse()?;
584599
derive_clone = value.value();
585600
}
601+
"partial" => {
602+
if input.peek(Token![=]) {
603+
// partial = ["field1", "field2"]
604+
input.parse::<Token![=]>()?;
605+
let content;
606+
let _ = bracketed!(content in input);
607+
let fields: Punctuated<LitStr, Token![,]> =
608+
content.parse_terminated(|input| input.parse::<LitStr>(), Token![,])?;
609+
partial = Some(PartialMode::Fields(
610+
fields.into_iter().map(|s| s.value()).collect(),
611+
));
612+
} else {
613+
// bare `partial` — all fields
614+
partial = Some(PartialMode::All);
615+
}
616+
}
586617
_ => {
587618
return Err(syn::Error::new(
588619
ident.span(),
589620
format!(
590-
"unknown parameter: `{}`. Expected `omit`, `pick`, `rename`, `add`, or `clone`",
621+
"unknown parameter: `{}`. Expected `omit`, `pick`, `rename`, `add`, `clone`, or `partial`",
591622
ident_str
592623
),
593624
));
@@ -611,6 +642,7 @@ impl Parse for SchemaTypeInput {
611642
rename,
612643
add,
613644
derive_clone,
645+
partial,
614646
})
615647
}
616648
}
@@ -719,12 +751,36 @@ pub fn generate_schema_type_code(
719751
}
720752
}
721753

754+
// Validate partial fields exist (when specific fields are listed)
755+
if let Some(PartialMode::Fields(ref partial_fields)) = input.partial {
756+
for field in partial_fields {
757+
if !source_field_names.contains(field) {
758+
return Err(syn::Error::new_spanned(
759+
&input.source_type,
760+
format!(
761+
"partial field `{}` does not exist in type `{}`. Available fields: {:?}",
762+
field,
763+
source_type_name,
764+
source_field_names.iter().collect::<Vec<_>>()
765+
),
766+
));
767+
}
768+
}
769+
}
770+
722771
// Build omit set (use Rust field names)
723772
let omit_set: HashSet<String> = input.omit.clone().unwrap_or_default().into_iter().collect();
724773

725774
// Build pick set (use Rust field names)
726775
let pick_set: HashSet<String> = input.pick.clone().unwrap_or_default().into_iter().collect();
727776

777+
// Build partial set
778+
let partial_all = matches!(input.partial, Some(PartialMode::All));
779+
let partial_set: HashSet<String> = match &input.partial {
780+
Some(PartialMode::Fields(fields)) => fields.iter().cloned().collect(),
781+
_ => HashSet::new(),
782+
};
783+
728784
// Build rename map: source_field_name -> new_field_name
729785
let rename_map: std::collections::HashMap<String, String> = input
730786
.rename
@@ -743,8 +799,8 @@ pub fn generate_schema_type_code(
743799
// Generate new struct with filtered fields
744800
let new_type_name = &input.new_type;
745801
let mut field_tokens = Vec::new();
746-
// Track field mappings for From impl: (new_field_ident, source_field_ident)
747-
let mut field_mappings: Vec<(syn::Ident, syn::Ident)> = Vec::new();
802+
// Track field mappings for From impl: (new_field_ident, source_field_ident, wrapped_in_option)
803+
let mut field_mappings: Vec<(syn::Ident, syn::Ident, bool)> = Vec::new();
748804

749805
if let syn::Fields::Named(fields_named) = &parsed_struct.fields {
750806
for field in &fields_named.named {
@@ -764,8 +820,15 @@ pub fn generate_schema_type_code(
764820
continue;
765821
}
766822

767-
// Get field components
768-
let field_ty = &field.ty;
823+
// Get field components, applying partial wrapping if needed
824+
let original_ty = &field.ty;
825+
let should_wrap_option = (partial_all || partial_set.contains(&rust_field_name))
826+
&& !is_option_type(original_ty);
827+
let field_ty: Box<dyn quote::ToTokens> = if should_wrap_option {
828+
Box::new(quote! { Option<#original_ty> })
829+
} else {
830+
Box::new(quote! { #original_ty })
831+
};
769832
let vis = &field.vis;
770833
let source_field_ident = field.ident.clone().unwrap();
771834

@@ -811,7 +874,7 @@ pub fn generate_schema_type_code(
811874
});
812875

813876
// Track mapping: new field name <- source field name
814-
field_mappings.push((new_field_ident, source_field_ident));
877+
field_mappings.push((new_field_ident, source_field_ident, should_wrap_option));
815878
} else {
816879
// No rename, keep field with only serde attrs
817880
let field_ident = field.ident.clone().unwrap();
@@ -822,7 +885,7 @@ pub fn generate_schema_type_code(
822885
});
823886

824887
// Track mapping: same name
825-
field_mappings.push((field_ident.clone(), field_ident));
888+
field_mappings.push((field_ident.clone(), field_ident, should_wrap_option));
826889
}
827890
}
828891
}
@@ -849,8 +912,12 @@ pub fn generate_schema_type_code(
849912
let from_impl = if input.add.is_none() {
850913
let field_assignments: Vec<_> = field_mappings
851914
.iter()
852-
.map(|(new_ident, source_ident)| {
853-
quote! { #new_ident: source.#source_ident }
915+
.map(|(new_ident, source_ident, wrapped)| {
916+
if *wrapped {
917+
quote! { #new_ident: Some(source.#source_ident) }
918+
} else {
919+
quote! { #new_ident: source.#source_ident }
920+
}
854921
})
855922
.collect();
856923

@@ -1051,6 +1118,119 @@ mod tests {
10511118
assert_eq!(add[0].0, "tags");
10521119
}
10531120

1121+
// Tests for `partial` parameter
1122+
1123+
#[test]
1124+
fn test_parse_schema_type_input_with_partial_all() {
1125+
let tokens = quote::quote!(UpdateUser from User, partial);
1126+
let input: SchemaTypeInput = syn::parse2(tokens).unwrap();
1127+
assert!(matches!(input.partial, Some(PartialMode::All)));
1128+
}
1129+
1130+
#[test]
1131+
fn test_parse_schema_type_input_with_partial_fields() {
1132+
let tokens = quote::quote!(UpdateUser from User, partial = ["name", "email"]);
1133+
let input: SchemaTypeInput = syn::parse2(tokens).unwrap();
1134+
match input.partial {
1135+
Some(PartialMode::Fields(fields)) => {
1136+
assert_eq!(fields, vec!["name", "email"]);
1137+
}
1138+
_ => panic!("Expected PartialMode::Fields"),
1139+
}
1140+
}
1141+
1142+
#[test]
1143+
fn test_parse_schema_type_input_with_pick_and_partial() {
1144+
let tokens = quote::quote!(UpdateUser from User, pick = ["name", "email"], partial);
1145+
let input: SchemaTypeInput = syn::parse2(tokens).unwrap();
1146+
assert_eq!(input.pick.unwrap(), vec!["name", "email"]);
1147+
assert!(matches!(input.partial, Some(PartialMode::All)));
1148+
}
1149+
1150+
#[test]
1151+
fn test_parse_schema_type_input_with_pick_and_partial_fields() {
1152+
let tokens =
1153+
quote::quote!(UpdateUser from User, pick = ["name", "email"], partial = ["name"]);
1154+
let input: SchemaTypeInput = syn::parse2(tokens).unwrap();
1155+
assert_eq!(input.pick.unwrap(), vec!["name", "email"]);
1156+
match input.partial {
1157+
Some(PartialMode::Fields(fields)) => {
1158+
assert_eq!(fields, vec!["name"]);
1159+
}
1160+
_ => panic!("Expected PartialMode::Fields"),
1161+
}
1162+
}
1163+
1164+
#[test]
1165+
fn test_generate_schema_type_code_with_partial_all() {
1166+
let storage = vec![create_test_struct_metadata(
1167+
"User",
1168+
"pub struct User { pub id: i32, pub name: String, pub bio: Option<String> }",
1169+
)];
1170+
1171+
let tokens = quote::quote!(UpdateUser from User, partial);
1172+
let input: SchemaTypeInput = syn::parse2(tokens).unwrap();
1173+
let result = generate_schema_type_code(&input, &storage);
1174+
1175+
assert!(result.is_ok());
1176+
let output = result.unwrap().to_string();
1177+
// id and name should be wrapped in Option, bio already Option stays unchanged
1178+
assert!(output.contains("Option < i32 >"));
1179+
assert!(output.contains("Option < String >"));
1180+
}
1181+
1182+
#[test]
1183+
fn test_generate_schema_type_code_with_partial_fields() {
1184+
let storage = vec![create_test_struct_metadata(
1185+
"User",
1186+
"pub struct User { pub id: i32, pub name: String, pub email: String }",
1187+
)];
1188+
1189+
let tokens = quote::quote!(UpdateUser from User, partial = ["name"]);
1190+
let input: SchemaTypeInput = syn::parse2(tokens).unwrap();
1191+
let result = generate_schema_type_code(&input, &storage);
1192+
1193+
assert!(result.is_ok());
1194+
let output = result.unwrap().to_string();
1195+
// name should be Option<String>, but id and email should remain unwrapped
1196+
assert!(output.contains("UpdateUser"));
1197+
}
1198+
1199+
#[test]
1200+
fn test_generate_schema_type_code_partial_nonexistent_field() {
1201+
let storage = vec![create_test_struct_metadata(
1202+
"User",
1203+
"pub struct User { pub id: i32, pub name: String }",
1204+
)];
1205+
1206+
let tokens = quote::quote!(UpdateUser from User, partial = ["nonexistent"]);
1207+
let input: SchemaTypeInput = syn::parse2(tokens).unwrap();
1208+
let result = generate_schema_type_code(&input, &storage);
1209+
1210+
assert!(result.is_err());
1211+
let err = result.unwrap_err().to_string();
1212+
assert!(err.contains("does not exist"));
1213+
assert!(err.contains("nonexistent"));
1214+
}
1215+
1216+
#[test]
1217+
fn test_generate_schema_type_code_partial_from_impl_wraps_some() {
1218+
let storage = vec![create_test_struct_metadata(
1219+
"User",
1220+
"pub struct User { pub id: i32, pub name: String }",
1221+
)];
1222+
1223+
let tokens = quote::quote!(UpdateUser from User, partial);
1224+
let input: SchemaTypeInput = syn::parse2(tokens).unwrap();
1225+
let result = generate_schema_type_code(&input, &storage);
1226+
1227+
assert!(result.is_ok());
1228+
let output = result.unwrap().to_string();
1229+
// From impl should wrap values in Some()
1230+
assert!(output.contains("Some (source . id)"));
1231+
assert!(output.contains("Some (source . name)"));
1232+
}
1233+
10541234
// =========================================================================
10551235
// Tests for generate_schema_code() - success paths
10561236
// =========================================================================

0 commit comments

Comments
 (0)