update grid4 & heroledger models

This commit is contained in:
Timur Gordon
2025-09-16 14:18:08 +02:00
parent cb1fb0f0ec
commit 53e9a2d4f0
31 changed files with 3216 additions and 399 deletions

View File

@@ -14,4 +14,6 @@ quote = "1.0"
proc-macro2 = "1.0"
[dev-dependencies]
serde = { version = "1.0", features = ["derive"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
heromodels_core = { path = "../heromodels_core" }

View File

@@ -1,6 +1,6 @@
use proc_macro::TokenStream;
use quote::{format_ident, quote};
use syn::{Data, DeriveInput, Fields, parse_macro_input};
use syn::{parse_macro_input, Data, DeriveInput, Fields, Lit, Meta, MetaList, MetaNameValue};
/// Convert a string to snake_case
fn to_snake_case(s: &str) -> String {
@@ -47,86 +47,165 @@ pub fn model(_attr: TokenStream, item: TokenStream) -> TokenStream {
let db_prefix = to_snake_case(&name_str);
// Extract fields with #[index] attribute
let mut indexed_fields = Vec::new();
let mut custom_index_names = std::collections::HashMap::new();
// Supports both top-level (no args) and nested path-based indexes declared on a field
#[derive(Clone)]
enum IndexDecl {
TopLevel {
field_ident: syn::Ident,
field_ty: syn::Type,
},
NestedPath {
on_field_ident: syn::Ident,
path: String, // dotted path relative to the field
},
}
let mut index_decls: Vec<IndexDecl> = Vec::new();
if let Data::Struct(ref mut data_struct) = input.data {
if let Fields::Named(ref mut fields_named) = data_struct.fields {
for field in &mut fields_named.named {
let mut attr_idx = None;
let mut to_remove: Vec<usize> = Vec::new();
for (i, attr) in field.attrs.iter().enumerate() {
if attr.path().is_ident("index") {
attr_idx = Some(i);
if let Some(ref field_name) = field.ident {
// Check if the attribute has parameters
let mut custom_name = None;
if !attr.path().is_ident("index") {
continue;
}
to_remove.push(i);
// Parse attribute arguments if any
let meta = attr.meta.clone();
if let syn::Meta::List(list) = meta {
if let Ok(nested) = list.parse_args_with(syn::punctuated::Punctuated::<syn::Meta, syn::Token![,]>::parse_terminated) {
for meta in nested {
if let syn::Meta::NameValue(name_value) = meta {
if name_value.path.is_ident("name") {
if let syn::Expr::Lit(syn::ExprLit { lit: syn::Lit::Str(lit_str), .. }) = name_value.value {
custom_name = Some(lit_str.value());
}
if let Some(ref field_name) = field.ident {
match &attr.meta {
Meta::Path(_) => {
// Simple top-level index on this field
index_decls.push(IndexDecl::TopLevel {
field_ident: field_name.clone(),
field_ty: field.ty.clone(),
});
}
Meta::List(MetaList { .. }) => {
// Parse for path = "..."; name is assumed equal to path
// We support syntax: #[index(path = "a.b.c")]
if let Ok(nested) = attr.parse_args_with(
syn::punctuated::Punctuated::<Meta, syn::Token![,]>::parse_terminated,
) {
for meta in nested {
if let Meta::NameValue(MetaNameValue { path, value, .. }) = meta {
if path.is_ident("path") {
if let syn::Expr::Lit(syn::ExprLit { lit: Lit::Str(lit_str), .. }) = value {
let p = lit_str.value();
index_decls.push(IndexDecl::NestedPath {
on_field_ident: field_name.clone(),
path: p,
});
}
}
}
}
}
}
indexed_fields.push((field_name.clone(), field.ty.clone()));
if let Some(name) = custom_name {
custom_index_names.insert(field_name.to_string(), name);
}
_ => {}
}
}
}
if let Some(idx) = attr_idx {
// remove all #[index] attributes we processed
// remove from the back to keep indices valid
to_remove.sort_unstable();
to_remove.drain(..).rev().for_each(|idx| {
field.attrs.remove(idx);
}
});
}
}
}
// Generate Model trait implementation
let db_keys_impl = if indexed_fields.is_empty() {
let db_keys_impl = if index_decls.is_empty() {
quote! {
fn db_keys(&self) -> Vec<heromodels_core::IndexKey> {
Vec::new()
}
}
} else {
let field_keys = indexed_fields.iter().map(|(field_name, _)| {
let name_str = custom_index_names
.get(&field_name.to_string())
.cloned()
.unwrap_or(field_name.to_string());
quote! {
heromodels_core::IndexKey {
name: #name_str,
value: self.#field_name.to_string(),
// Build code for keys from each index declaration
let mut key_snippets: Vec<proc_macro2::TokenStream> = Vec::new();
for decl in &index_decls {
match decl.clone() {
IndexDecl::TopLevel { field_ident, .. } => {
let name_str = field_ident.to_string();
key_snippets.push(quote! {
keys.push(heromodels_core::IndexKey {
name: #name_str,
value: self.#field_ident.to_string(),
});
});
}
IndexDecl::NestedPath { on_field_ident, path } => {
// Name is equal to provided path
let name_str = path.clone();
// Generate traversal code using serde_json to support arrays and objects generically
// Split the path into static segs for iteration
let segs: Vec<String> = path.split('.').map(|s| s.to_string()).collect();
let segs_iter = segs.iter().map(|s| s.as_str());
let segs_array = quote! { [ #( #segs_iter ),* ] };
key_snippets.push(quote! {
// Serialize the target field to JSON for generic traversal
let __hm_json_val = ::serde_json::to_value(&self.#on_field_ident).unwrap_or(::serde_json::Value::Null);
let mut __hm_stack: Vec<&::serde_json::Value> = vec![&__hm_json_val];
for __hm_seg in #segs_array.iter() {
let mut __hm_next: Vec<&::serde_json::Value> = Vec::new();
for __hm_v in &__hm_stack {
match __hm_v {
::serde_json::Value::Array(arr) => {
for __hm_e in arr {
if let ::serde_json::Value::Object(map) = __hm_e {
if let Some(x) = map.get(*__hm_seg) { __hm_next.push(x); }
}
}
}
::serde_json::Value::Object(map) => {
if let Some(x) = map.get(*__hm_seg) { __hm_next.push(x); }
}
_ => {}
}
}
__hm_stack = __hm_next;
if __hm_stack.is_empty() { break; }
}
for __hm_leaf in __hm_stack {
match __hm_leaf {
::serde_json::Value::Null => {},
::serde_json::Value::Array(_) => {},
::serde_json::Value::Object(_) => {},
other => {
// Convert primitives to string without surrounding quotes for strings
let mut s = other.to_string();
if let ::serde_json::Value::String(_) = other { s = s.trim_matches('"').to_string(); }
keys.push(heromodels_core::IndexKey { name: #name_str, value: s });
}
}
}
});
}
}
});
}
quote! {
fn db_keys(&self) -> Vec<heromodels_core::IndexKey> {
vec![
#(#field_keys),*
]
let mut keys: Vec<heromodels_core::IndexKey> = Vec::new();
#(#key_snippets)*
keys
}
}
};
let indexed_field_names = indexed_fields
let indexed_field_names: Vec<String> = index_decls
.iter()
.map(|f| f.0.to_string())
.collect::<Vec<_>>();
.map(|d| match d {
IndexDecl::TopLevel { field_ident, .. } => field_ident.to_string(),
IndexDecl::NestedPath { path, .. } => path.clone(),
})
.collect();
let model_impl = quote! {
impl heromodels_core::Model for #struct_name {
@@ -152,51 +231,33 @@ pub fn model(_attr: TokenStream, item: TokenStream) -> TokenStream {
}
};
// Generate Index trait implementations
// Generate Index trait implementations only for top-level fields, keep existing behavior
let mut index_impls = proc_macro2::TokenStream::new();
for decl in &index_decls {
if let IndexDecl::TopLevel { field_ident, field_ty } = decl {
let name_str = field_ident.to_string();
let index_struct_name = format_ident!("{}", &name_str);
let field_type = field_ty.clone();
for (field_name, field_type) in &indexed_fields {
let name_str = field_name.to_string();
let index_impl = quote! {
pub struct #index_struct_name;
// Get custom index name if specified, otherwise use field name
let index_key = match custom_index_names.get(&name_str) {
Some(custom_name) => custom_name.clone(),
None => name_str.clone(),
};
impl heromodels_core::Index for #index_struct_name {
type Model = super::#struct_name;
type Key = #field_type;
// 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);
let index_struct_name = format_ident!("{}", &name_str);
fn key() -> &'static str { #name_str }
// Default to str for key type
let index_impl = quote! {
pub struct #index_struct_name;
impl heromodels_core::Index for #index_struct_name {
type Model = super::#struct_name;
type Key = #field_type;
fn key() -> &'static str {
#index_key
fn field_name() -> &'static str { #name_str }
}
fn field_name() -> &'static str {
#name_str
}
}
};
index_impls.extend(index_impl);
};
index_impls.extend(index_impl);
}
}
if !index_impls.is_empty() {
let index_mod_name = format_ident!("{}_index", db_prefix);
index_impls = quote! {
pub mod #index_mod_name {
#index_impls
}
}
index_impls = quote! { pub mod #index_mod_name { #index_impls } };
}
// Combine the original struct with the generated implementations

View File

@@ -1,7 +1,38 @@
use heromodels_derive::model;
use serde::{Deserialize, Serialize};
// Define the necessary structs and traits for testing
// Make the current crate visible as an extern crate named `heromodels_core`
extern crate self as heromodels_core;
extern crate serde_json; // ensure ::serde_json path resolves
// Mock the heromodels_core API at crate root (visible via the alias above)
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IndexKey {
pub name: &'static str,
pub value: String,
}
pub trait Model: std::fmt::Debug + Clone + Serialize + for<'de> Deserialize<'de> + Send + Sync + 'static {
fn db_prefix() -> &'static str
where
Self: Sized;
fn get_id(&self) -> u32;
fn base_data_mut(&mut self) -> &mut BaseModelData;
fn db_keys(&self) -> Vec<IndexKey> {
Vec::new()
}
fn indexed_fields() -> Vec<&'static str> {
Vec::new()
}
}
pub trait Index {
type Model: Model;
type Key: ToString + ?Sized;
fn key() -> &'static str;
fn field_name() -> &'static str;
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BaseModelData {
pub id: u32,
@@ -11,41 +42,18 @@ pub struct BaseModelData {
}
impl BaseModelData {
pub fn new(id: u32) -> Self {
let now = 1000; // Mock timestamp
Self {
id,
created_at: now,
modified_at: now,
comments: Vec::new(),
}
pub fn new() -> Self {
let now = 1000;
Self { id: 0, created_at: now, modified_at: now, comments: Vec::new() }
}
pub fn update_modified(&mut self) { self.modified_at += 1; }
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IndexKey {
pub name: &'static str,
pub value: String,
}
pub trait Model: std::fmt::Debug + Clone {
fn db_prefix() -> &'static str;
fn get_id(&self) -> u32;
fn base_data_mut(&mut self) -> &mut BaseModelData;
fn db_keys(&self) -> Vec<IndexKey>;
}
pub trait Index {
type Model: Model;
type Key: ?Sized;
fn key() -> &'static str;
}
// Test struct using the model macro
// Top-level field index tests
#[derive(Debug, Clone, Serialize, Deserialize)]
#[model]
struct TestUser {
base_data: BaseModelData,
pub struct TestUser {
base_data: heromodels_core::BaseModelData,
#[index]
username: String,
@@ -54,25 +62,12 @@ struct TestUser {
is_active: bool,
}
// Test struct with custom index name
#[derive(Debug, Clone, Serialize, Deserialize)]
#[model]
struct TestUserWithCustomIndex {
base_data: BaseModelData,
#[index(name = "custom_username")]
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),
base_data: heromodels_core::BaseModelData::new(),
username: "test".to_string(),
is_active: true,
};
@@ -85,22 +80,47 @@ fn test_basic_model() {
assert_eq!(keys[1].value, "true");
}
// Nested path index tests (including vector traversal)
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct GPU { gpu_brand: String }
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct CPU { cpu_brand: String }
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct DeviceInfo { vendor: String, cpu: Vec<CPU>, gpu: Vec<GPU> }
#[derive(Debug, Clone, Serialize, Deserialize)]
#[model]
pub struct NodeLike {
base_data: heromodels_core::BaseModelData,
#[index(path = "vendor")]
#[index(path = "cpu.cpu_brand")]
#[index(path = "gpu.gpu_brand")]
devices: DeviceInfo,
}
#[test]
fn test_custom_index_name() {
let user = TestUserWithCustomIndex {
base_data: BaseModelData::new(1),
username: "test".to_string(),
is_active: true,
fn test_nested_indexes() {
let n = NodeLike {
base_data: heromodels_core::BaseModelData::new(),
devices: DeviceInfo {
vendor: "SuperVendor".to_string(),
cpu: vec![CPU { cpu_brand: "Intel".into() }, CPU { cpu_brand: "AMD".into() }],
gpu: vec![GPU { gpu_brand: "NVIDIA".into() }, GPU { gpu_brand: "AMD".into() }],
},
};
// Check that the Username struct uses the custom index name
assert_eq!(Username::key(), "custom_username");
let mut keys = n.db_keys();
// Sort for deterministic assertions
keys.sort_by(|a,b| a.name.cmp(b.name).then(a.value.cmp(&b.value)));
// Check that the db_keys method returns the correct keys
let keys = user.db_keys();
assert_eq!(keys.len(), 2);
assert_eq!(keys[0].name, "custom_username");
assert_eq!(keys[0].value, "test");
assert_eq!(keys[1].name, "is_active");
assert_eq!(keys[1].value, "true");
// Expect 1 (vendor) + 2 (cpu brands) + 2 (gpu brands) = 5 keys
assert_eq!(keys.len(), 5);
assert!(keys.iter().any(|k| k.name == "vendor" && k.value == "SuperVendor"));
assert!(keys.iter().any(|k| k.name == "cpu.cpu_brand" && k.value == "Intel"));
assert!(keys.iter().any(|k| k.name == "cpu.cpu_brand" && k.value == "AMD"));
assert!(keys.iter().any(|k| k.name == "gpu.gpu_brand" && k.value == "NVIDIA"));
assert!(keys.iter().any(|k| k.name == "gpu.gpu_brand" && k.value == "AMD"));
}