Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changepacks/changepack_log_EOqDZl3jRWhOYS6fCfVK9.json
Original file line number Diff line number Diff line change
@@ -0,0 +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"}
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions crates/vespera_macro/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,8 @@ pub fn schema(input: TokenStream) -> TokenStream {
/// - `pick = [...]`: List of field names to include (excludes all others)
/// - `omit = [...]`: List of field names to exclude
/// - `clone = bool`: Whether to derive Clone (default: true)
/// - `partial`: Make all fields `Option<T>` (fields already `Option<T>` are unchanged)
/// - `partial = [...]`: Make only listed fields `Option<T>`
///
/// Note: `omit` and `pick` cannot be used together.
///
Expand Down
199 changes: 190 additions & 9 deletions crates/vespera_macro/src/schema_macro.rs
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,21 @@ pub struct SchemaTypeInput {
pub add: Option<Vec<(String, Type)>>,
/// Whether to derive Clone (default: true)
pub derive_clone: bool,
/// Fields to wrap in `Option<T>` for partial updates.
///
/// - `partial` (bare) = all fields become `Option<T>`
/// - `partial = ["field1", "field2"]` = only listed fields become `Option<T>`
/// - Fields already `Option<T>` are left unchanged.
pub partial: Option<PartialMode>,
}

/// Mode for the `partial` keyword in schema_type!
#[derive(Clone, Debug)]
pub enum PartialMode {
/// All fields become Option<T>
All,
/// Only listed fields become Option<T>
Fields(Vec<String>),
}

/// Helper struct to parse an add field: ("field_name": Type)
Expand Down Expand Up @@ -533,6 +548,7 @@ impl Parse for SchemaTypeInput {
let mut rename = None;
let mut add = None;
let mut derive_clone = true;
let mut partial = None;

// Parse optional parameters
while input.peek(Token![,]) {
Expand Down Expand Up @@ -583,11 +599,27 @@ impl Parse for SchemaTypeInput {
let value: syn::LitBool = input.parse()?;
derive_clone = value.value();
}
"partial" => {
if input.peek(Token![=]) {
// partial = ["field1", "field2"]
input.parse::<Token![=]>()?;
let content;
let _ = bracketed!(content in input);
let fields: Punctuated<LitStr, Token![,]> =
content.parse_terminated(|input| input.parse::<LitStr>(), Token![,])?;
partial = Some(PartialMode::Fields(
fields.into_iter().map(|s| s.value()).collect(),
));
} else {
// bare `partial` — all fields
partial = Some(PartialMode::All);
}
}
_ => {
return Err(syn::Error::new(
ident.span(),
format!(
"unknown parameter: `{}`. Expected `omit`, `pick`, `rename`, `add`, or `clone`",
"unknown parameter: `{}`. Expected `omit`, `pick`, `rename`, `add`, `clone`, or `partial`",
ident_str
),
));
Expand All @@ -611,6 +643,7 @@ impl Parse for SchemaTypeInput {
rename,
add,
derive_clone,
partial,
})
}
}
Expand Down Expand Up @@ -719,12 +752,36 @@ pub fn generate_schema_type_code(
}
}

// Validate partial fields exist (when specific fields are listed)
if let Some(PartialMode::Fields(ref partial_fields)) = input.partial {
for field in partial_fields {
if !source_field_names.contains(field) {
return Err(syn::Error::new_spanned(
&input.source_type,
format!(
"partial field `{}` does not exist in type `{}`. Available fields: {:?}",
field,
source_type_name,
source_field_names.iter().collect::<Vec<_>>()
),
));
}
}
}

// Build omit set (use Rust field names)
let omit_set: HashSet<String> = input.omit.clone().unwrap_or_default().into_iter().collect();

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

// Build partial set
let partial_all = matches!(input.partial, Some(PartialMode::All));
let partial_set: HashSet<String> = match &input.partial {
Some(PartialMode::Fields(fields)) => fields.iter().cloned().collect(),
_ => HashSet::new(),
};

// Build rename map: source_field_name -> new_field_name
let rename_map: std::collections::HashMap<String, String> = input
.rename
Expand All @@ -743,8 +800,8 @@ pub fn generate_schema_type_code(
// Generate new struct with filtered fields
let new_type_name = &input.new_type;
let mut field_tokens = Vec::new();
// Track field mappings for From impl: (new_field_ident, source_field_ident)
let mut field_mappings: Vec<(syn::Ident, syn::Ident)> = Vec::new();
// Track field mappings for From impl: (new_field_ident, source_field_ident, wrapped_in_option)
let mut field_mappings: Vec<(syn::Ident, syn::Ident, bool)> = Vec::new();

if let syn::Fields::Named(fields_named) = &parsed_struct.fields {
for field in &fields_named.named {
Expand All @@ -764,8 +821,15 @@ pub fn generate_schema_type_code(
continue;
}

// Get field components
let field_ty = &field.ty;
// Get field components, applying partial wrapping if needed
let original_ty = &field.ty;
let should_wrap_option = (partial_all || partial_set.contains(&rust_field_name))
&& !is_option_type(original_ty);
let field_ty: Box<dyn quote::ToTokens> = if should_wrap_option {
Box::new(quote! { Option<#original_ty> })
} else {
Box::new(quote! { #original_ty })
};
let vis = &field.vis;
let source_field_ident = field.ident.clone().unwrap();

Expand Down Expand Up @@ -811,7 +875,7 @@ pub fn generate_schema_type_code(
});

// Track mapping: new field name <- source field name
field_mappings.push((new_field_ident, source_field_ident));
field_mappings.push((new_field_ident, source_field_ident, should_wrap_option));
} else {
// No rename, keep field with only serde attrs
let field_ident = field.ident.clone().unwrap();
Expand All @@ -822,7 +886,7 @@ pub fn generate_schema_type_code(
});

// Track mapping: same name
field_mappings.push((field_ident.clone(), field_ident));
field_mappings.push((field_ident.clone(), field_ident, should_wrap_option));
}
}
}
Expand All @@ -849,8 +913,12 @@ pub fn generate_schema_type_code(
let from_impl = if input.add.is_none() {
let field_assignments: Vec<_> = field_mappings
.iter()
.map(|(new_ident, source_ident)| {
quote! { #new_ident: source.#source_ident }
.map(|(new_ident, source_ident, wrapped)| {
if *wrapped {
quote! { #new_ident: Some(source.#source_ident) }
} else {
quote! { #new_ident: source.#source_ident }
}
})
.collect();

Expand Down Expand Up @@ -1051,6 +1119,119 @@ mod tests {
assert_eq!(add[0].0, "tags");
}

// Tests for `partial` parameter

#[test]
fn test_parse_schema_type_input_with_partial_all() {
let tokens = quote::quote!(UpdateUser from User, partial);
let input: SchemaTypeInput = syn::parse2(tokens).unwrap();
assert!(matches!(input.partial, Some(PartialMode::All)));
}

#[test]
fn test_parse_schema_type_input_with_partial_fields() {
let tokens = quote::quote!(UpdateUser from User, partial = ["name", "email"]);
let input: SchemaTypeInput = syn::parse2(tokens).unwrap();
match input.partial {
Some(PartialMode::Fields(fields)) => {
assert_eq!(fields, vec!["name", "email"]);
}
_ => panic!("Expected PartialMode::Fields"),
}
}

#[test]
fn test_parse_schema_type_input_with_pick_and_partial() {
let tokens = quote::quote!(UpdateUser from User, pick = ["name", "email"], partial);
let input: SchemaTypeInput = syn::parse2(tokens).unwrap();
assert_eq!(input.pick.unwrap(), vec!["name", "email"]);
assert!(matches!(input.partial, Some(PartialMode::All)));
}

#[test]
fn test_parse_schema_type_input_with_pick_and_partial_fields() {
let tokens =
quote::quote!(UpdateUser from User, pick = ["name", "email"], partial = ["name"]);
let input: SchemaTypeInput = syn::parse2(tokens).unwrap();
assert_eq!(input.pick.unwrap(), vec!["name", "email"]);
match input.partial {
Some(PartialMode::Fields(fields)) => {
assert_eq!(fields, vec!["name"]);
}
_ => panic!("Expected PartialMode::Fields"),
}
}

#[test]
fn test_generate_schema_type_code_with_partial_all() {
let storage = vec![create_test_struct_metadata(
"User",
"pub struct User { pub id: i32, pub name: String, pub bio: Option<String> }",
)];

let tokens = quote::quote!(UpdateUser from User, partial);
let input: SchemaTypeInput = syn::parse2(tokens).unwrap();
let result = generate_schema_type_code(&input, &storage);

assert!(result.is_ok());
let output = result.unwrap().to_string();
// id and name should be wrapped in Option, bio already Option stays unchanged
assert!(output.contains("Option < i32 >"));
assert!(output.contains("Option < String >"));
}

#[test]
fn test_generate_schema_type_code_with_partial_fields() {
let storage = vec![create_test_struct_metadata(
"User",
"pub struct User { pub id: i32, pub name: String, pub email: String }",
)];

let tokens = quote::quote!(UpdateUser from User, partial = ["name"]);
let input: SchemaTypeInput = syn::parse2(tokens).unwrap();
let result = generate_schema_type_code(&input, &storage);

assert!(result.is_ok());
let output = result.unwrap().to_string();
// name should be Option<String>, but id and email should remain unwrapped
assert!(output.contains("UpdateUser"));
}

#[test]
fn test_generate_schema_type_code_partial_nonexistent_field() {
let storage = vec![create_test_struct_metadata(
"User",
"pub struct User { pub id: i32, pub name: String }",
)];

let tokens = quote::quote!(UpdateUser from User, partial = ["nonexistent"]);
let input: SchemaTypeInput = syn::parse2(tokens).unwrap();
let result = generate_schema_type_code(&input, &storage);

assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("does not exist"));
assert!(err.contains("nonexistent"));
}

#[test]
fn test_generate_schema_type_code_partial_from_impl_wraps_some() {
let storage = vec![create_test_struct_metadata(
"User",
"pub struct User { pub id: i32, pub name: String }",
)];

let tokens = quote::quote!(UpdateUser from User, partial);
let input: SchemaTypeInput = syn::parse2(tokens).unwrap();
let result = generate_schema_type_code(&input, &storage);

assert!(result.is_ok());
let output = result.unwrap().to_string();
// From impl should wrap values in Some()
assert!(output.contains("Some (source . id)"));
assert!(output.contains("Some (source . name)"));
}

// =========================================================================
// Tests for generate_schema_code() - success paths
// =========================================================================
Expand Down