From dc93518a3523d920093c3a3338d12ce0a1d2d5e2 Mon Sep 17 00:00:00 2001 From: Lee Smet Date: Fri, 25 Apr 2025 11:01:18 +0200 Subject: [PATCH] Add proc macro to implement models Signed-off-by: Lee Smet --- heromodels/Cargo.lock | 10 ++ heromodels/Cargo.toml | 1 + heromodels/examples/custom_model_example.rs | 37 ++++ heromodels/examples/model_macro_example.rs | 58 +++++++ heromodels/examples/simple_model_example.rs | 29 ++++ heromodels/heromodels-derive/Cargo.toml | 14 ++ heromodels/heromodels-derive/src/lib.rs | 158 ++++++++++++++++++ .../tests/test_model_macro.rs | 31 ++++ heromodels/src/lib.rs | 3 + heromodels/src/models/userexample/user.rs | 1 - 10 files changed, 341 insertions(+), 1 deletion(-) create mode 100644 heromodels/examples/custom_model_example.rs create mode 100644 heromodels/examples/model_macro_example.rs create mode 100644 heromodels/examples/simple_model_example.rs create mode 100644 heromodels/heromodels-derive/Cargo.toml create mode 100644 heromodels/heromodels-derive/src/lib.rs create mode 100644 heromodels/heromodels-derive/tests/test_model_macro.rs diff --git a/heromodels/Cargo.lock b/heromodels/Cargo.lock index 7c756a5..726bf29 100644 --- a/heromodels/Cargo.lock +++ b/heromodels/Cargo.lock @@ -262,11 +262,21 @@ dependencies = [ "bincode", "chrono", "fjall", + "heromodels-derive", "ourdb", "serde", "tst", ] +[[package]] +name = "heromodels-derive" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "iana-time-zone" version = "0.1.63" diff --git a/heromodels/Cargo.toml b/heromodels/Cargo.toml index 6f04222..948edbb 100644 --- a/heromodels/Cargo.toml +++ b/heromodels/Cargo.toml @@ -12,3 +12,4 @@ chrono = { version = "0.4", features = ["serde"] } fjall = "2.9.0" ourdb = { path = "../ourdb" } tst = { path = "../tst" } +heromodels-derive = { path = "./heromodels-derive" } diff --git a/heromodels/examples/custom_model_example.rs b/heromodels/examples/custom_model_example.rs new file mode 100644 index 0000000..4ff42d5 --- /dev/null +++ b/heromodels/examples/custom_model_example.rs @@ -0,0 +1,37 @@ +use heromodels::model; +use heromodels::models::core::model::{BaseModelData, Model, Index, IndexKey}; +use serde::{Serialize, Deserialize}; + +// Define a custom attribute for indexing +#[derive(Debug, Clone, Serialize, Deserialize)] +#[model] +pub struct CustomUser { + pub base_data: BaseModelData, + + // Mark fields for indexing with a comment + // #[index] + pub login: String, + + // #[index] + pub is_active: bool, + + pub full_name: String, +} + +fn main() { + println!("Hero Models - Custom Model Example"); + println!("=================================="); + + // Example usage of the generated implementation + println!("CustomUser DB Prefix: {}", CustomUser::db_prefix()); + + let user = CustomUser { + base_data: BaseModelData::new(1), + login: "johndoe".to_string(), + is_active: true, + full_name: "John Doe".to_string(), + }; + + println!("\nCustomUser ID: {}", user.get_id()); + println!("CustomUser DB Keys: {:?}", user.db_keys()); +} \ No newline at end of file diff --git a/heromodels/examples/model_macro_example.rs b/heromodels/examples/model_macro_example.rs new file mode 100644 index 0000000..85bbf0f --- /dev/null +++ b/heromodels/examples/model_macro_example.rs @@ -0,0 +1,58 @@ +use heromodels::model; +use heromodels::models::core::model::{BaseModelData, Model, Index, IndexKey}; +use serde::{Serialize, Deserialize}; + +// Basic usage +#[derive(Debug, Clone, Serialize, Deserialize)] +#[model] +pub struct SimpleUser { + pub base_data: BaseModelData, + + /// @index + pub login: String, + + pub full_name: String, +} + +// With customization options +#[derive(Debug, Clone, Serialize, Deserialize)] +#[model(prefix = "custom_user")] +pub struct CustomUser { + pub base_data: BaseModelData, + + /// @index(name = "user_name", key_type = "str") + pub login_name: String, + + /// @index(key_type = "bool") + pub is_active: bool, + + pub full_name: String, +} + +fn main() { + println!("Hero Models - Model Macro Example"); + println!("================================="); + + // Example usage of the generated implementations + println!("SimpleUser DB Prefix: {}", SimpleUser::db_prefix()); + println!("CustomUser DB Prefix: {}", CustomUser::db_prefix()); + + let user = SimpleUser { + base_data: BaseModelData::new(1), + login: "johndoe".to_string(), + full_name: "John Doe".to_string(), + }; + + let custom_user = CustomUser { + base_data: BaseModelData::new(2), + login_name: "janesmith".to_string(), + is_active: true, + full_name: "Jane Smith".to_string(), + }; + + println!("\nSimpleUser ID: {}", user.get_id()); + println!("SimpleUser DB Keys: {:?}", user.db_keys()); + + println!("\nCustomUser ID: {}", custom_user.get_id()); + println!("CustomUser DB Keys: {:?}", custom_user.db_keys()); +} \ No newline at end of file diff --git a/heromodels/examples/simple_model_example.rs b/heromodels/examples/simple_model_example.rs new file mode 100644 index 0000000..c8a0355 --- /dev/null +++ b/heromodels/examples/simple_model_example.rs @@ -0,0 +1,29 @@ +use heromodels::model; +use heromodels::models::core::model::{BaseModelData, Model, IndexKey}; +use serde::{Serialize, Deserialize}; + +// Basic usage +#[derive(Debug, Clone, Serialize, Deserialize)] +#[model] +pub struct SimpleUser { + pub base_data: BaseModelData, + pub login: String, + pub full_name: String, +} + +fn main() { + println!("Hero Models - Simple Model Example"); + println!("=================================="); + + // Example usage of the generated implementation + println!("SimpleUser DB Prefix: {}", SimpleUser::db_prefix()); + + let user = SimpleUser { + base_data: BaseModelData::new(1), + login: "johndoe".to_string(), + full_name: "John Doe".to_string(), + }; + + println!("\nSimpleUser ID: {}", user.get_id()); + println!("SimpleUser DB Keys: {:?}", user.db_keys()); +} \ No newline at end of file diff --git a/heromodels/heromodels-derive/Cargo.toml b/heromodels/heromodels-derive/Cargo.toml new file mode 100644 index 0000000..1bb53f4 --- /dev/null +++ b/heromodels/heromodels-derive/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "heromodels-derive" +version = "0.1.0" +edition = "2024" +description = "Derive macros for heromodels" +authors = ["Your Name "] + +[lib] +proc-macro = true + +[dependencies] +syn = { version = "2.0", features = ["full", "extra-traits", "parsing"] } +quote = "1.0" +proc-macro2 = "1.0" \ No newline at end of file diff --git a/heromodels/heromodels-derive/src/lib.rs b/heromodels/heromodels-derive/src/lib.rs new file mode 100644 index 0000000..c78d6bd --- /dev/null +++ b/heromodels/heromodels-derive/src/lib.rs @@ -0,0 +1,158 @@ +use proc_macro::TokenStream; +use quote::{quote, format_ident}; +use syn::{parse_macro_input, DeriveInput, Data, Fields}; + +/// Convert a string to snake_case +fn to_snake_case(s: &str) -> String { + let mut result = String::new(); + for (i, c) in s.char_indices() { + if i > 0 && c.is_uppercase() { + result.push('_'); + } + result.push(c.to_lowercase().next().unwrap()); + } + result +} + +/// Convert a string to PascalCase +fn to_pascal_case(s: &str) -> String { + let mut result = String::new(); + let mut capitalize_next = true; + + for c in s.chars() { + if c == '_' { + capitalize_next = true; + } else if capitalize_next { + result.push(c.to_uppercase().next().unwrap()); + capitalize_next = false; + } else { + result.push(c); + } + } + + result +} + + +/// Implements the Model trait and generates Index trait implementations for fields marked with #[index]. +#[proc_macro_attribute] +pub fn model(_attr: TokenStream, item: TokenStream) -> TokenStream { + // Parse the input tokens into a syntax tree + let input = parse_macro_input!(item as DeriveInput); + + // Extract struct name + let struct_name = &input.ident; + + // Convert struct name to snake_case for db_prefix + let name_str = struct_name.to_string(); + let db_prefix = to_snake_case(&name_str); + + // Extract fields with /// @index doc comment + let mut indexed_fields = Vec::new(); + + if let Data::Struct(data_struct) = &input.data { + if let Fields::Named(fields_named) = &data_struct.fields { + for field in &fields_named.named { + for attr in &field.attrs { + if attr.path().is_ident("doc") { + let meta = attr.meta.clone().try_into().unwrap(); + if let syn::Meta::NameValue(name_value) = meta { + if let syn::Expr::Lit(syn::ExprLit { lit: syn::Lit::Str(lit_str), .. }) = name_value.value { + let doc_str = lit_str.value(); + if doc_str.trim().starts_with("@index") { + if let Some(field_name) = &field.ident { + indexed_fields.push(field_name); + } + } + } + } + } + } + } + } + } + + // Generate Model trait implementation + let db_keys_impl = if indexed_fields.is_empty() { + quote! { + fn db_keys(&self) -> Vec { + Vec::new() + } + } + } else { + let field_keys = indexed_fields.iter().map(|field_name| { + let name_str = field_name.to_string(); + quote! { + IndexKey { + name: #name_str, + value: self.#field_name.to_string(), + } + } + }); + + quote! { + fn db_keys(&self) -> Vec { + vec![ + #(#field_keys),* + ] + } + } + }; + + let model_impl = quote! { + impl Model for #struct_name { + fn db_prefix() -> &'static str { + #db_prefix + } + + fn get_id(&self) -> u32 { + self.base_data.id + } + + fn base_data_mut(&mut self) -> &mut BaseModelData { + &mut self.base_data + } + + #db_keys_impl + } + }; + + // Generate Index trait implementations + let mut index_impls = proc_macro2::TokenStream::new(); + + for field_name in &indexed_fields { + let name_str = field_name.to_string(); + + // Convert field name to PascalCase for struct name + let struct_name_str = to_pascal_case(&name_str); + let index_struct_name = format_ident!("{}", struct_name_str); + + // Default to str for key type + let index_impl = quote! { + pub struct #index_struct_name; + + impl Index for #index_struct_name { + type Model = #struct_name; + type Key = str; + + fn key() -> &'static str { + #name_str + } + } + }; + + index_impls.extend(index_impl); + } + + // Combine the original struct with the generated implementations + let expanded = quote! { + #input + + #model_impl + + #index_impls + }; + + // Return the generated code + expanded.into() +} diff --git a/heromodels/heromodels-derive/tests/test_model_macro.rs b/heromodels/heromodels-derive/tests/test_model_macro.rs new file mode 100644 index 0000000..bc75ae4 --- /dev/null +++ b/heromodels/heromodels-derive/tests/test_model_macro.rs @@ -0,0 +1,31 @@ +use heromodels::model; +use heromodels::models::core::model::{BaseModelData, Model, Index}; + +#[model] +struct TestUser { + base_data: BaseModelData, + + #[index] + username: String, + + #[index] + is_active: bool, +} + +#[test] +fn test_basic_model() { + assert_eq!(TestUser::db_prefix(), "test_user"); + + let user = TestUser { + base_data: BaseModelData::new(1), + username: "test".to_string(), + is_active: true, + }; + + let keys = user.db_keys(); + assert_eq!(keys.len(), 2); + assert_eq!(keys[0].name, "username"); + assert_eq!(keys[0].value, "test"); + assert_eq!(keys[1].name, "is_active"); + assert_eq!(keys[1].value, "true"); +} \ No newline at end of file diff --git a/heromodels/src/lib.rs b/heromodels/src/lib.rs index 58f4ea7..8bc137e 100644 --- a/heromodels/src/lib.rs +++ b/heromodels/src/lib.rs @@ -3,3 +3,6 @@ pub mod models; /// Database implementations pub mod db; + +// Re-export the procedural macro +pub use heromodels_derive::model; diff --git a/heromodels/src/models/userexample/user.rs b/heromodels/src/models/userexample/user.rs index c2a2924..0b8e7e7 100644 --- a/heromodels/src/models/userexample/user.rs +++ b/heromodels/src/models/userexample/user.rs @@ -145,4 +145,3 @@ impl Index for IsActive { "is_active" } } -