diff --git a/CHANGELOG.md b/CHANGELOG.md index 92f8b84fb4..b40c18ba64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### New Features +* Split `belongs_to` from `has_one` with a new `BelongsTo` relation type https://github.com/SeaQL/sea-orm/pull/3118 + + A `belongs_to` relation can now be typed `BelongsTo` (required) or + `BelongsTo>` (optional), encoding the foreign-key cardinality in the type, + paired with the write-side companion `ActiveBelongsTo`. `BelongsTo` is the recommended + type for `belongs_to`; the legacy `HasOne` field type remains supported for + backward compatibility. + * Role Based Access Control https://github.com/SeaQL/sea-orm/pull/2683 1. a hierarchical RBAC engine that is table scoped diff --git a/CLAUDE.md b/CLAUDE.md index 75d0848c6f..9780ac0aa9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,7 +51,7 @@ pub posts: HasMany, // Belongs-To (explicit foreign key mapping) #[sea_orm(belongs_to, from = "user_id", to = "id")] -pub user: HasOne, +pub user: BelongsTo, // Many-to-Many via junction table #[sea_orm(has_many, via = "post_tag")] @@ -74,9 +74,9 @@ pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub tag_id: i32, #[sea_orm(belongs_to, from = "post_id", to = "id")] - pub post: Option, + pub post: BelongsTo, #[sea_orm(belongs_to, from = "tag_id", to = "id")] - pub tag: Option, + pub tag: BelongsTo, } ``` diff --git a/Cargo.toml b/Cargo.toml index 1dd031fe59..ce18c65048 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,7 @@ bigdecimal = { version = "0.4", default-features = false, features = [ "std", ], optional = true } chrono = { version = "0.4.30", default-features = false, optional = true } +derive-where = "1.6.1" derive_more = { version = "2", features = ["debug"] } futures-util = { version = "0.3", default-features = false, features = [ "std", diff --git a/README-zh.md b/README-zh.md index 5d5266c7fe..d2bbba8b25 100644 --- a/README-zh.md +++ b/README-zh.md @@ -91,7 +91,7 @@ mod post { pub user_id: i32, pub title: String, #[sea_orm(belongs_to, from = "user_id", to = "id")] - pub author: HasOne, + pub author: BelongsTo, #[sea_orm(has_many, via = "post_tag")] // 多对多关系,使用中间表 pub tags: HasMany, } @@ -124,12 +124,9 @@ smart_user id: 42, name: "Bob".into(), email: "bob@sea-ql.org".into(), - profile: HasOne::Loaded( - profile::ModelEx { - picture: "image.jpg".into(), - } - .into(), - ), + profile: HasOne::loaded(Some(profile::ModelEx { + picture: "image.jpg".into(), + })), posts: HasMany::Loaded(vec![post::ModelEx { title: "Nice weather".into(), tags: HasMany::Loaded(vec![tag::ModelEx { diff --git a/README.md b/README.md index 84f8d47e0c..ad37470954 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ mod post { pub user_id: i32, pub title: String, #[sea_orm(belongs_to, from = "user_id", to = "id")] - pub author: HasOne, + pub author: BelongsTo, #[sea_orm(has_many, via = "post_tag")] // M-N relation with junction pub tags: HasMany, } @@ -124,12 +124,9 @@ smart_user id: 42, name: "Bob".into(), email: "bob@sea-ql.org".into(), - profile: HasOne::Loaded( - profile::ModelEx { - picture: "image.jpg".into(), - } - .into(), - ), + profile: HasOne::loaded(Some(profile::ModelEx { + picture: "image.jpg".into(), + })), posts: HasMany::Loaded(vec![post::ModelEx { title: "Nice weather".into(), tags: HasMany::Loaded(vec![tag::ModelEx { diff --git a/examples/basic/src/entity/cake_filling.rs b/examples/basic/src/entity/cake_filling.rs index a99c53f3c1..5b96116750 100644 --- a/examples/basic/src/entity/cake_filling.rs +++ b/examples/basic/src/entity/cake_filling.rs @@ -17,7 +17,7 @@ pub struct Model { on_update = "Cascade", on_delete = "Cascade" )] - pub cake: HasOne, + pub cake: BelongsTo, #[sea_orm( belongs_to, from = "filling_id", @@ -25,7 +25,7 @@ pub struct Model { on_update = "Cascade", on_delete = "Cascade" )] - pub filling: HasOne, + pub filling: BelongsTo, } impl ActiveModelBehavior for ActiveModel {} diff --git a/examples/basic/src/entity/fruit.rs b/examples/basic/src/entity/fruit.rs index 21cfc120ef..a67c524156 100644 --- a/examples/basic/src/entity/fruit.rs +++ b/examples/basic/src/entity/fruit.rs @@ -11,7 +11,7 @@ pub struct Model { pub name: String, pub cake_id: Option, #[sea_orm(belongs_to, from = "cake_id", to = "id")] - pub cake: HasOne, + pub cake: BelongsTo>, } impl ActiveModelBehavior for ActiveModel {} diff --git a/examples/loco_seaography/src/models/_entities/files.rs b/examples/loco_seaography/src/models/_entities/files.rs index 186b0cb912..b86f7441d7 100644 --- a/examples/loco_seaography/src/models/_entities/files.rs +++ b/examples/loco_seaography/src/models/_entities/files.rs @@ -14,5 +14,5 @@ pub struct Model { pub notes_id: i32, pub file_path: String, #[sea_orm(belongs_to, from = "notes_id", to = "id")] - pub notes: HasOne, + pub notes: BelongsTo, } diff --git a/examples/quickstart/src/main.rs b/examples/quickstart/src/main.rs index a907ebf2bb..92a767c5f5 100644 --- a/examples/quickstart/src/main.rs +++ b/examples/quickstart/src/main.rs @@ -38,7 +38,7 @@ mod profile { #[sea_orm(unique)] pub user_id: i32, #[sea_orm(belongs_to, from = "user_id", to = "id")] - pub user: HasOne, + pub user: BelongsTo, } impl ActiveModelBehavior for ActiveModel {} @@ -56,7 +56,7 @@ mod post { pub user_id: i32, pub title: String, #[sea_orm(belongs_to, from = "user_id", to = "id")] - pub author: HasOne, + pub author: BelongsTo, #[sea_orm(has_many)] pub comments: HasMany, #[sea_orm(has_many, via = "post_tag")] @@ -79,9 +79,9 @@ mod comment { pub user_id: i32, pub post_id: i32, #[sea_orm(belongs_to, from = "user_id", to = "id")] - pub user: HasOne, + pub user: BelongsTo, #[sea_orm(belongs_to, from = "post_id", to = "id")] - pub post: HasOne, + pub post: BelongsTo, } impl ActiveModelBehavior for ActiveModel {} @@ -117,9 +117,9 @@ mod post_tag { #[sea_orm(primary_key, auto_increment = false)] pub tag_id: i32, #[sea_orm(belongs_to, from = "post_id", to = "id")] - pub post: Option, + pub post: BelongsTo, #[sea_orm(belongs_to, from = "tag_id", to = "id")] - pub tag: Option, + pub tag: BelongsTo, } impl ActiveModelBehavior for ActiveModel {} @@ -137,14 +137,14 @@ mod user_follower { #[sea_orm(primary_key)] pub follower_id: i32, #[sea_orm(belongs_to, from = "user_id", to = "id")] - pub user: Option, + pub user: BelongsTo, #[sea_orm( belongs_to, relation_enum = "Follower", from = "follower_id", to = "id" )] - pub follower: Option, + pub follower: BelongsTo, } impl ActiveModelBehavior for ActiveModel {} diff --git a/examples/react_admin/backend/src/models/_entities/files.rs b/examples/react_admin/backend/src/models/_entities/files.rs index 186b0cb912..b86f7441d7 100644 --- a/examples/react_admin/backend/src/models/_entities/files.rs +++ b/examples/react_admin/backend/src/models/_entities/files.rs @@ -14,5 +14,5 @@ pub struct Model { pub notes_id: i32, pub file_path: String, #[sea_orm(belongs_to, from = "notes_id", to = "id")] - pub notes: HasOne, + pub notes: BelongsTo, } diff --git a/examples/seaography_example/graphql/src/entities/baker.rs b/examples/seaography_example/graphql/src/entities/baker.rs index 0bc36253a5..0bc4d55e0a 100644 --- a/examples/seaography_example/graphql/src/entities/baker.rs +++ b/examples/seaography_example/graphql/src/entities/baker.rs @@ -18,7 +18,7 @@ pub struct Model { on_update = "Cascade", on_delete = "Cascade" )] - pub bakery: HasOne, + pub bakery: BelongsTo>, #[sea_orm(has_many, via = "cake_baker")] pub cakes: HasMany, } diff --git a/examples/seaography_example/graphql/src/entities/cake.rs b/examples/seaography_example/graphql/src/entities/cake.rs index 07d333d0fd..ec47256c88 100644 --- a/examples/seaography_example/graphql/src/entities/cake.rs +++ b/examples/seaography_example/graphql/src/entities/cake.rs @@ -20,7 +20,7 @@ pub struct Model { on_update = "Cascade", on_delete = "Cascade" )] - pub bakery: HasOne, + pub bakery: BelongsTo, #[sea_orm(has_many, via = "cake_baker")] pub bakers: HasMany, } diff --git a/examples/seaography_example/graphql/src/entities/cake_baker.rs b/examples/seaography_example/graphql/src/entities/cake_baker.rs index a8d846394b..210e5d3ac9 100644 --- a/examples/seaography_example/graphql/src/entities/cake_baker.rs +++ b/examples/seaography_example/graphql/src/entities/cake_baker.rs @@ -17,7 +17,7 @@ pub struct Model { on_update = "Cascade", on_delete = "Cascade" )] - pub baker: HasOne, + pub baker: BelongsTo, #[sea_orm( belongs_to, from = "cake_id", @@ -25,7 +25,7 @@ pub struct Model { on_update = "Cascade", on_delete = "Cascade" )] - pub cake: HasOne, + pub cake: BelongsTo, } impl ActiveModelBehavior for ActiveModel {} diff --git a/sea-orm-codegen/src/entity/writer/dense.rs b/sea-orm-codegen/src/entity/writer/dense.rs index 58e7119517..4632c2dea9 100644 --- a/sea-orm-codegen/src/entity/writer/dense.rs +++ b/sea-orm-codegen/src/entity/writer/dense.rs @@ -1,6 +1,144 @@ use super::*; -use crate::{Relation, RelationType}; +use crate::{Entity, Relation, RelationType}; use heck::ToSnakeCase; +use sea_query::ForeignKeyAction; + +fn relation_column_name(a: &syn::Ident) -> String { + let a = a.to_string(); + let b = a.to_snake_case(); + if a != b.to_upper_camel_case() { + // if roundtrip fails, use original + a + } else { + b + } +} + +fn relation_column_list(punctuated: Vec) -> String { + let len = punctuated.len(); + let punctuated = punctuated.join(", "); + match len { + 0..=1 => punctuated, + _ => format!("({punctuated})"), + } +} + +fn foreign_key_action_attr( + attr: TokenStream, + action: Option<&ForeignKeyAction>, +) -> Option { + action.map(|action| { + let action = Relation::get_foreign_key_action(action); + quote!(, #attr = #action) + }) +} + +struct DenseRelationField<'a> { + entity: &'a Entity, + rel: &'a Relation, + via_entities: &'a [syn::Ident], +} + +impl DenseRelationField<'_> { + fn field_tokens(&self) -> Option { + let (field, target_entity) = if self.rel.self_referencing { + let table_name = self.entity.get_table_name_snake_case_ident(); + let suffix = if self.rel.num_suffix > 0 { + format!("_{}", self.rel.num_suffix) + } else { + String::new() + }; + let field = format_ident!("{table_name}{suffix}"); + + (field, quote!(Entity)) + } else { + if !self.rel.impl_related { + return None; + } + + let to_entity = self.rel.get_module_name()?; + if self.via_entities.contains(&to_entity) { + return None; + } + + let field = match self.rel.rel_type { + RelationType::HasMany => { + let to_entity = to_entity.to_string(); + let pluralized = pluralizer::pluralize(&to_entity, 2, false); + format_ident!("{pluralized}") + } + RelationType::HasOne | RelationType::BelongsTo => to_entity.clone(), + }; + let field = if self.rel.num_suffix == 0 { + field + } else { + format_ident!("{field}_{}", self.rel.num_suffix) + }; + (field, quote!(super::#to_entity::Entity)) + }; + + let rel_field_type = match self.rel.rel_type { + RelationType::BelongsTo => { + let is_optional = !self.rel.columns.is_empty() + && self.rel.columns.iter().all(|name| { + self.entity + .columns + .iter() + .find(|column| column.name == *name) + .is_some_and(|column| column.not_null) + }); + if is_optional { + quote!(BelongsTo<#target_entity>) + } else { + quote!(BelongsTo>) + } + } + RelationType::HasOne => quote!(HasOne<#target_entity>), + RelationType::HasMany => quote!(HasMany<#target_entity>), + }; + let sea_orm_attr = if self.rel.self_referencing { + let (from, to) = self.rel.get_src_ref_columns( + relation_column_name, + relation_column_name, + relation_column_list, + ); + let on_update = foreign_key_action_attr(quote!(on_update), self.rel.on_update.as_ref()); + let on_delete = foreign_key_action_attr(quote!(on_delete), self.rel.on_delete.as_ref()); + let relation_enum = self.rel.get_enum_name().to_string(); + + quote!(#[sea_orm(self_ref, relation_enum = #relation_enum, from = #from, to = #to #on_update #on_delete)]) + } else { + match self.rel.rel_type { + RelationType::HasOne => quote!(#[sea_orm(has_one)]), + RelationType::HasMany => quote!(#[sea_orm(has_many)]), + RelationType::BelongsTo => { + let (from, to) = self.rel.get_src_ref_columns( + relation_column_name, + relation_column_name, + relation_column_list, + ); + let on_update = + foreign_key_action_attr(quote!(on_update), self.rel.on_update.as_ref()); + let on_delete = + foreign_key_action_attr(quote!(on_delete), self.rel.on_delete.as_ref()); + let relation_enum = if self.rel.num_suffix > 0 { + let relation_enum = self.rel.get_enum_name().to_string(); + Some(quote!(relation_enum = #relation_enum,)) + } else { + None + }; + + quote!(#[sea_orm(belongs_to, #relation_enum from = #from, to = #to #on_update #on_delete)]) + } + } + }; + + Some(quote! { + #sea_orm_attr + pub #field: #rel_field_type + }) + } +} impl EntityWriter { #[allow(clippy::too_many_arguments)] @@ -124,111 +262,15 @@ impl EntityWriter { let mut compound_objects: Punctuated<_, Comma> = Punctuated::new(); - let map_col = |a: &syn::Ident| -> String { - let a = a.to_string(); - let b = a.to_snake_case(); - if a != b.to_upper_camel_case() { - // if roundtrip fails, use original - a - } else { - b - } - }; - let map_punctuated = |punctuated: Vec| -> String { - let len = punctuated.len(); - let punctuated = punctuated.join(", "); - match len { - 0..=1 => punctuated, - _ => format!("({punctuated})"), - } - }; - let via_entities = entity.get_conjunct_relations_via_snake_case(); for rel in entity.relations.iter() { - if !rel.self_referencing && rel.impl_related { - let (rel_type, sea_orm_attr) = match rel.rel_type { - RelationType::HasOne => (format_ident!("HasOne"), quote!(#[sea_orm(has_one)])), - RelationType::HasMany => { - (format_ident!("HasMany"), quote!(#[sea_orm(has_many)])) - } - RelationType::BelongsTo => { - let (from, to) = rel.get_src_ref_columns(map_col, map_col, map_punctuated); - let on_update = if let Some(action) = &rel.on_update { - let action = Relation::get_foreign_key_action(action); - quote!(, on_update = #action) - } else { - quote!() - }; - let on_delete = if let Some(action) = &rel.on_delete { - let action = Relation::get_foreign_key_action(action); - quote!(, on_delete = #action) - } else { - quote!() - }; - let relation_enum = if rel.num_suffix > 0 { - let relation_enum = rel.get_enum_name().to_string(); - quote!(relation_enum = #relation_enum,) - } else { - quote!() - }; - ( - format_ident!("HasOne"), - quote!(#[sea_orm(belongs_to, #relation_enum from = #from, to = #to #on_update #on_delete)]), - ) - } - }; - - if let Some(to_entity) = rel.get_module_name() - && !via_entities.contains(&to_entity) - { - // skip junctions - let field = if matches!(rel.rel_type, RelationType::HasMany) { - format_ident!( - "{}", - pluralizer::pluralize(&to_entity.to_string(), 2, false) - ) - } else { - to_entity.clone() - }; - let field = if rel.num_suffix == 0 { - field - } else { - format_ident!("{field}_{}", rel.num_suffix) - }; - compound_objects.push(quote! { - #sea_orm_attr - pub #field: #rel_type - }); - } - } else if rel.self_referencing { - let (from, to) = rel.get_src_ref_columns(map_col, map_col, map_punctuated); - let on_update = if let Some(action) = &rel.on_update { - let action = Relation::get_foreign_key_action(action); - quote!(, on_update = #action) - } else { - quote!() - }; - let on_delete = if let Some(action) = &rel.on_delete { - let action = Relation::get_foreign_key_action(action); - quote!(, on_delete = #action) - } else { - quote!() - }; - let relation_enum = rel.get_enum_name().to_string(); - let field = format_ident!( - "{}{}", - entity.get_table_name_snake_case_ident(), - if rel.num_suffix > 0 { - format!("_{}", rel.num_suffix) - } else { - "".into() - } - ); - - compound_objects.push(quote! { - #[sea_orm(self_ref, relation_enum = #relation_enum, from = #from, to = #to #on_update #on_delete)] - pub #field: HasOne - }); + let relation_field = DenseRelationField { + entity, + rel, + via_entities: &via_entities, + }; + if let Some(field) = relation_field.field_tokens() { + compound_objects.push(field); } } for (to_entity, via_entity) in entity diff --git a/sea-orm-codegen/tests/dense/cake_filling.rs b/sea-orm-codegen/tests/dense/cake_filling.rs index 035f681637..c405dbcca0 100644 --- a/sea-orm-codegen/tests/dense/cake_filling.rs +++ b/sea-orm-codegen/tests/dense/cake_filling.rs @@ -11,9 +11,9 @@ pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub filling_id: i32, #[sea_orm(belongs_to, from = "cake_id", to = "id", on_update = "Cascade", on_delete = "Cascade")] - pub cake: HasOne , + pub cake: BelongsTo , #[sea_orm(belongs_to, from = "filling_id", to = "id", on_update = "Cascade", on_delete = "Cascade")] - pub filling: HasOne , + pub filling: BelongsTo , } impl ActiveModelBehavior for ActiveModel {} diff --git a/sea-orm-codegen/tests/dense/cake_filling_price.rs b/sea-orm-codegen/tests/dense/cake_filling_price.rs index 3d0f6c0807..c0fb800034 100644 --- a/sea-orm-codegen/tests/dense/cake_filling_price.rs +++ b/sea-orm-codegen/tests/dense/cake_filling_price.rs @@ -16,7 +16,7 @@ pub struct Model { from = "(cake_id, filling_id)", to = "(cake_id, filling_id)" )] - pub cake_filling: HasOne , + pub cake_filling: BelongsTo , } impl ActiveModelBehavior for ActiveModel {} \ No newline at end of file diff --git a/sea-orm-codegen/tests/dense/child.rs b/sea-orm-codegen/tests/dense/child.rs index 55d1779cf4..cf8634018b 100644 --- a/sea-orm-codegen/tests/dense/child.rs +++ b/sea-orm-codegen/tests/dense/child.rs @@ -11,7 +11,7 @@ pub struct Model { pub parent_id1: i32, pub parent_id2: i32, #[sea_orm(belongs_to, from = "(parent_id1, parent_id2)", to = "(id1, id2)")] - pub parent: HasOne , + pub parent: BelongsTo , } impl ActiveModelBehavior for ActiveModel {} diff --git a/sea-orm-codegen/tests/dense/fruit.rs b/sea-orm-codegen/tests/dense/fruit.rs index 697fe8316b..6e6675f15d 100644 --- a/sea-orm-codegen/tests/dense/fruit.rs +++ b/sea-orm-codegen/tests/dense/fruit.rs @@ -11,7 +11,7 @@ pub struct Model { pub name: String, pub cake_id: Option , #[sea_orm(belongs_to, from = "cake_id", to = "id")] - pub cake: HasOne , + pub cake: BelongsTo> , #[sea_orm(has_many)] pub vendors: HasMany , } diff --git a/sea-orm-codegen/tests/dense/rust_keyword.rs b/sea-orm-codegen/tests/dense/rust_keyword.rs index d62cf5ade4..32e9b8b387 100644 --- a/sea-orm-codegen/tests/dense/rust_keyword.rs +++ b/sea-orm-codegen/tests/dense/rust_keyword.rs @@ -21,15 +21,15 @@ pub struct Model { pub fruit_id2: i32, pub cake_id: i32, #[sea_orm(self_ref, relation_enum = "SelfRef1", from = "self_id1", to = "id")] - pub rust_keyword_1: HasOne , + pub rust_keyword_1: BelongsTo , #[sea_orm(self_ref, relation_enum = "SelfRef2", from = "self_id2", to = "id")] - pub rust_keyword_2: HasOne , + pub rust_keyword_2: BelongsTo , #[sea_orm(belongs_to, relation_enum = "Fruit1", from = "fruit_id1", to = "id")] - pub fruit_1: HasOne , + pub fruit_1: BelongsTo , #[sea_orm(belongs_to, relation_enum = "Fruit2", from = "fruit_id2", to = "id")] - pub fruit_2: HasOne , + pub fruit_2: BelongsTo , #[sea_orm(belongs_to, from = "cake_id", to = "id")] - pub cake: HasOne , + pub cake: BelongsTo , } impl ActiveModelBehavior for ActiveModel {} diff --git a/sea-orm-codegen/tests/dense/vendor.rs b/sea-orm-codegen/tests/dense/vendor.rs index 0c48ad0f93..107cdc210d 100644 --- a/sea-orm-codegen/tests/dense/vendor.rs +++ b/sea-orm-codegen/tests/dense/vendor.rs @@ -13,7 +13,7 @@ pub struct Model { #[sea_orm(column_name = "fruitId")] pub fruit_id: Option , #[sea_orm(belongs_to, from = "fruit_id", to = "id")] - pub fruit: HasOne , + pub fruit: BelongsTo> , } impl ActiveModelBehavior for ActiveModel {} diff --git a/sea-orm-macros/Cargo.toml b/sea-orm-macros/Cargo.toml index d457e9fe52..e3a30d8a85 100644 --- a/sea-orm-macros/Cargo.toml +++ b/sea-orm-macros/Cargo.toml @@ -30,6 +30,7 @@ syn = { version = "2", default-features = false, features = [ "proc-macro", "derive", "printing", + "extra-traits", ] } unicode-ident = { version = "1" } diff --git a/sea-orm-macros/src/derives/active_model.rs b/sea-orm-macros/src/derives/active_model.rs index 6d9a6cbda9..c1c53ac5af 100644 --- a/sea-orm-macros/src/derives/active_model.rs +++ b/sea-orm-macros/src/derives/active_model.rs @@ -1,6 +1,4 @@ -use super::util::{ - escape_rust_keyword, field_not_ignored, format_field_ident, trim_starting_raw_identifier, -}; +use super::util::{escape_rust_keyword, field_not_ignored, trim_starting_raw_identifier}; use heck::ToUpperCamelCase; use proc_macro2::{Ident, TokenStream}; use quote::{format_ident, quote}; @@ -34,9 +32,10 @@ impl DeriveActiveModel { let mut types = Vec::new(); for field in all_fields.iter().filter(|f| field_not_ignored(f)) { - fields.push(format_field_ident(field)); + let field_ident = field.ident.as_ref().expect("named fields have identifiers"); + fields.push(field_ident.clone()); - let ident = field.ident.as_ref().unwrap().to_string(); + let ident = field_ident.to_string(); let ident = trim_starting_raw_identifier(ident).to_upper_camel_case(); let ident = escape_rust_keyword(ident); let mut ident = format_ident!("{}", &ident); @@ -233,10 +232,13 @@ fn derive_into_model(ident: &Ident, data: &Data) -> syn::Result { let active_model_field: Vec = model_fields .iter() .filter(|f| field_not_ignored(f)) - .map(format_field_ident) + .map(|field| field.ident.clone().expect("named fields have identifiers")) .collect(); - let model_field: Vec = model_fields.iter().map(format_field_ident).collect(); + let model_field: Vec = model_fields + .iter() + .map(|field| field.ident.clone().expect("named fields have identifiers")) + .collect(); let ignore_attr: Vec = model_fields.iter().map(|f| !field_not_ignored(f)).collect(); diff --git a/sea-orm-macros/src/derives/active_model_ex.rs b/sea-orm-macros/src/derives/active_model_ex.rs index 77afda2816..7367a027b0 100644 --- a/sea-orm-macros/src/derives/active_model_ex.rs +++ b/sea-orm-macros/src/derives/active_model_ex.rs @@ -1,231 +1,938 @@ use super::active_model::DeriveActiveModel; use super::attributes::compound_attr; use super::model_ex::infer_relation_name_from_entity; -use super::util::{extract_compound_entity, field_not_ignored_compound, is_compound_field}; +use super::util::{ + CardinalityKind, CompoundKind, CompoundType, async_token, await_token, consume_meta, + escape_rust_keyword, is_self_entity, trim_starting_raw_identifier, +}; use heck::ToUpperCamelCase; use proc_macro2::{Ident, Span, TokenStream}; use quote::{format_ident, quote}; -use std::collections::HashMap; -use syn::{Attribute, Data, Expr, Fields, LitStr, Type, Visibility}; +use syn::{ + Attribute, Data, LitStr, Path, PathArguments, Type, TypePath, Visibility, parse::Parser, + punctuated::Punctuated, token::Comma, +}; + +enum RelationAttr { + BelongsTo { + cardinality: CardinalityKind, + from: LitStr, + /// Explicit relation disambiguator, present when more than one relation + /// targets the same entity. Routes the FK write through the + /// relation-keyed `*_parent_key_for` helpers instead of the entity-keyed ones. + relation_enum: Option, + }, + BelongsToSelf { + relation_enum: LitStr, + cardinality: CardinalityKind, + from: LitStr, + }, + /// Legacy `belongs_to` declared with the `HasOne` field type instead of + /// `BelongsTo`. Backward-compatible path; `BelongsTo` is recommended. + BelongsToLegacy { + /// Explicit relation disambiguator (see `BelongsTo::relation_enum`). + relation_enum: Option, + }, + /// Legacy self-referential `belongs_to` declared with `HasOne`. + BelongsToSelfLegacy { + relation_enum: LitStr, + }, + HasOne, + HasMany, + HasManySelf { + relation_enum: LitStr, + }, + HasManyVia { + via: LitStr, + }, + HasManyViaSelf { + via: LitStr, + reverse: bool, + }, +} -pub fn expand_derive_active_model_ex( - vis: &Visibility, - ident: &Ident, - data: &Data, - attrs: &[Attribute], -) -> syn::Result { - let mut compact = false; - let mut model_fields = Vec::new(); - let mut ignored_model_fields = Vec::new(); - let mut field_types: Vec = Vec::new(); - let mut scalar_fields = Vec::new(); - let mut compound_fields = Vec::new(); - let mut belongs_to_fields = Vec::new(); - let mut belongs_to_self_fields = Vec::new(); - let mut has_one_fields = Vec::new(); - let mut has_many_fields = Vec::new(); - let mut has_many_self_fields = Vec::new(); - let mut has_many_via_fields = Vec::new(); - let mut has_many_via_self_fields = Vec::new(); - - let (async_, await_) = async_await(); +impl RelationAttr { + fn from_attr( + attrs: &compound_attr::SeaOrm, + ident: &Ident, + compound: &CompoundType, + ) -> syn::Result> { + match attrs { + compound_attr::SeaOrm { + self_ref: Some(_), + via: Some(via), + .. + } => { + if compound.kind != CompoundKind::HasMany || !is_self_entity(&compound.entity) { + return Err(syn::Error::new_spanned( + via, + "self_ref + via field type must be `HasMany`", + )); + } + Ok(Some(Self::HasManyViaSelf { + via: via.clone(), + reverse: attrs.reverse.is_some(), + })) + } + compound_attr::SeaOrm { + relation_enum: Some(relation_enum), + self_ref: Some(_), + via: None, + from: Some(from), + to: Some(_), + .. + } => { + match compound.kind { + CompoundKind::BelongsTo(cardinality) => Ok(Some(Self::BelongsToSelf { + relation_enum: relation_enum.clone(), + cardinality, + from: from.clone(), + })), + // Legacy: self-referential belongs_to with a `HasOne` field type. + CompoundKind::HasOne => Ok(Some(Self::BelongsToSelfLegacy { + relation_enum: relation_enum.clone(), + })), + _ => Err(syn::Error::new_spanned( + ident, + "self_ref belongs_to must be paired with BelongsTo or HasOne", + )), + } + } + compound_attr::SeaOrm { + relation_enum: Some(relation_enum), + self_ref: Some(_), + relation_reverse: Some(_), + via: None, + from: None, + to: None, + .. + } => { + if compound.kind != CompoundKind::HasMany || !is_self_entity(&compound.entity) { + return Err(syn::Error::new_spanned( + ident, + "self_ref has_many field type must be `HasMany`", + )); + } + Ok(Some(Self::HasManySelf { + relation_enum: relation_enum.clone(), + })) + } + compound_attr::SeaOrm { + self_ref: Some(_), + via: None, + relation_enum: None, + .. + } => Err(syn::Error::new_spanned( + ident, + "Please specify `relation_enum` for `self_ref`", + )), + compound_attr::SeaOrm { + self_ref: Some(_), .. + } => Ok(None), + compound_attr::SeaOrm { + belongs_to: Some(_), + .. + } => match compound.kind { + CompoundKind::BelongsTo(cardinality) => Ok(Some(Self::BelongsTo { + cardinality, + from: attrs.from.clone().ok_or_else(|| { + syn::Error::new_spanned(ident, "belongs_to must specify `from`") + })?, + // Captured so multiple belongs_to targeting the same entity can be + // disambiguated via their relation enum on save. + relation_enum: attrs.relation_enum.clone(), + })), + // Legacy: belongs_to declared with a `HasOne` field type instead of + // `BelongsTo`. Backward compatible; `BelongsTo` is recommended. + CompoundKind::HasOne => Ok(Some(Self::BelongsToLegacy { + relation_enum: attrs.relation_enum.clone(), + })), + _ => Err(syn::Error::new_spanned( + ident, + "belongs_to must be paired with BelongsTo or HasOne", + )), + }, + compound_attr::SeaOrm { + relation_enum: Some(_), + .. + } => Ok(None), + compound_attr::SeaOrm { + has_one: Some(_), .. + } => { + if compound.kind != CompoundKind::HasOne { + return Err(syn::Error::new_spanned( + ident, + "#[sea_orm(has_one)] must be paired with HasOne", + )); + } + Ok(Some(Self::HasOne)) + } + compound_attr::SeaOrm { + has_many: Some(_), + via: None, + .. + } => { + if compound.kind != CompoundKind::HasMany { + return Err(syn::Error::new_spanned( + ident, + "has_many must be paired with HasMany", + )); + } + Ok(Some(Self::HasMany)) + } + compound_attr::SeaOrm { + has_many: Some(_), + via: Some(via), + .. + } => { + if compound.kind != CompoundKind::HasMany { + return Err(syn::Error::new_spanned( + ident, + "has_many via must be paired with HasMany", + )); + } + Ok(Some(Self::HasManyVia { via: via.clone() })) + } + _ => Ok(None), + } + } +} + +struct ScalarField<'a> { + ty: &'a Type, + column: Ident, +} + +impl<'a> ScalarField<'a> { + fn from_field(field: &'a syn::Field, ident: &Ident) -> syn::Result { + let column = trim_starting_raw_identifier(ident).to_upper_camel_case(); + let mut column = Ident::new(&escape_rust_keyword(column), ident.span()); + + for attr in &field.attrs { + if !attr.path().is_ident("sea_orm") { + continue; + } - attrs - .iter() - .filter(|attr| attr.path().is_ident("sea_orm")) - .try_for_each(|attr| { attr.parse_nested_meta(|meta| { - if meta.path.is_ident("compact_model") { - compact = true; + if meta.path.is_ident("enum_name") { + column = syn::parse_str(&meta.value()?.parse::()?.value())?; } else { - // Reads the value expression to advance the parse stream. - let _: Option = meta.value().and_then(|v| v.parse()).ok(); + consume_meta(meta); } Ok(()) + })?; + } + + Ok(Self { + ty: &field.ty, + column, + }) + } +} + +#[derive(Clone, Copy)] +enum FieldParseMode { + Compact, + Dense, +} + +struct CompoundField { + compound: CompoundType, +} + +struct RelationField { + compound: CompoundType, + relation: RelationAttr, +} + +enum FieldKind<'a> { + Ignored, + Scalar(ScalarField<'a>), + Compound(CompoundField), + Relation(RelationField), +} + +struct Field<'a> { + ident: &'a Ident, + kind: FieldKind<'a>, +} + +impl<'a> Field<'a> { + fn from_field(field: &'a syn::Field, mode: FieldParseMode) -> syn::Result { + let Some(ident) = &field.ident else { + return Err(syn::Error::new_spanned(field, "expected named field")); + }; + let mut ignored = false; + for attr in &field.attrs { + if !attr.path().is_ident("sea_orm") { + continue; + } + + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("ignore") { + ignored = true; + } else { + consume_meta(meta); + } + Ok(()) + })?; + } + + let kind = if ignored { + FieldKind::Ignored + } else if let Type::Path(type_path) = &field.ty + && let Some(compound) = CompoundType::from_type(type_path)? + { + let relation = match mode { + FieldParseMode::Compact => None, + FieldParseMode::Dense => { + let attrs = compound_attr::SeaOrm::try_from_attributes(&field.attrs)? + .unwrap_or_default(); + RelationAttr::from_attr(&attrs, ident, &compound)? + } + }; + if let Some(relation) = relation { + FieldKind::Relation(RelationField { compound, relation }) + } else { + FieldKind::Compound(CompoundField { compound }) + } + } else { + FieldKind::Scalar(ScalarField::from_field(field, ident)?) + }; + Ok(Self { ident, kind }) + } + + fn expand_into(&'a self, fields: &'a Fields<'a>, output: &mut Output<'a>) -> syn::Result<()> { + let ident = self.ident; + match &self.kind { + FieldKind::Ignored => { + output.ignored_model_fields.push(ident); + } + FieldKind::Scalar(scalar) => { + let field_type = scalar.ty; + output.model_field_defs.push(quote! { + #[doc = " Generated by sea-orm-macros"] + pub #ident: sea_orm::ActiveValue<#field_type> + }); + output.active_model_setters.extend( + ActiveModelSetter { + field: self, + fields, + } + .expand()?, + ); + output.scalar_fields.push(ident); + } + FieldKind::Compound(CompoundField { compound }) + | FieldKind::Relation(RelationField { compound, .. }) => { + self.expand_compound_into(fields, output, compound)?; + } + } + + Ok(()) + } + + fn expand_compound_into( + &'a self, + fields: &'a Fields<'a>, + output: &mut Output<'a>, + compound: &CompoundType, + ) -> syn::Result<()> { + let ident = self.ident; + let entity_type = &compound.entity; + let field_type = match compound.kind { + CompoundKind::BelongsTo(cardinality) => { + let target_type = match cardinality { + CardinalityKind::Required => quote!(#entity_type), + CardinalityKind::Optional => quote!(Option<#entity_type>), + }; + quote!(ActiveBelongsTo<#target_type>) + } + CompoundKind::HasOne => quote!(ActiveHasOne<#entity_type>), + CompoundKind::HasMany => quote!(ActiveHasMany<#entity_type>), + }; + output.model_field_defs.push(quote! { + #[doc = " Generated by sea-orm-macros"] + pub #ident: #field_type + }); + output.active_model_setters.extend( + ActiveModelSetter { + field: self, + fields, + } + .expand()?, + ); + output.compound_fields.push(ident); + + Ok(()) + } +} + +struct Fields<'a>(Vec>); + +impl<'a> Fields<'a> { + fn from_data(data: &'a Data, ident: &Ident, mode: FieldParseMode) -> syn::Result { + let fields = if let Data::Struct(r#struct) = data + && let syn::Fields::Named(fields) = &r#struct.fields + { + fields + .named + .iter() + .map(|field| Field::from_field(field, mode)) + .collect::>>()? + } else { + return Err(syn::Error::new_spanned( + ident, + "You can only derive DeriveActiveModelEx on structs", + )); + }; + + Ok(Self(fields)) + } + + /// Return the fields corresponding to the `from` columns of a relation. + /// For example: + /// #[sea_orm(belogns_to, from = "cake_id")], #[sea_orm(belongs_to, from = "Column::CakeId")] will return the field corresponding to `cake_id`. + /// #[sea_orm(belongs_to, from = "(user_id, post_id)")] will return the fields corresponding to `user_id` and `post_id`. + fn get_nullable_from_fields(&'a self, columns_lit: &LitStr) -> syn::Result> { + let columns = if columns_lit.value().starts_with('(') { + let parser = Punctuated::::parse_terminated; + columns_lit.parse_with(|input: syn::parse::ParseStream<'_>| { + let content; + syn::parenthesized!(content in input); + parser.parse2(content.parse()?) + })? + } else { + let mut columns = Punctuated::new(); + columns.push(columns_lit.parse()?); + columns + }; + + columns.iter().try_fold(Vec::new(), |mut fields, column| { + let Some(column) = column.segments.last() else { + return Err(syn::Error::new_spanned(column, "expected column path")); + }; + let column = Ident::new( + &escape_rust_keyword(column.ident.to_string().to_upper_camel_case()), + column.ident.span(), + ); + let (field, scalar) = self + .0 + .iter() + .find_map(|field| { + if let FieldKind::Scalar(scalar) = &field.kind + && scalar.column == column + { + Some((field.ident, scalar)) + } else { + None + } + }) + .ok_or_else(|| { + syn::Error::new( + columns_lit.span(), + format!("unknown `from` column `{column}`"), + ) + })?; + if let Type::Path(type_path) = scalar.ty + && type_path + .path + .segments + .last() + .is_some_and(|segment| segment.ident == "Option") + { + fields.push(field); + } + Ok(fields) + }) + } + + fn entity_count(&self, entity: &TypePath) -> usize { + self.0 + .iter() + .filter(|field| match &field.kind { + FieldKind::Compound(compound_field) => &compound_field.compound.entity == entity, + FieldKind::Relation(relation_field) => &relation_field.compound.entity == entity, + _ => false, }) - })?; + .count() + } +} - let mut entity_count = HashMap::new(); - - if let Data::Struct(item_struct) = &data { - if let Fields::Named(fields) = &item_struct.fields { - for field in fields.named.iter() { - if field.ident.is_some() && field_not_ignored_compound(field) { - let field_type = &field.ty; - let field_type: String = quote! { #field_type } - .to_string() // e.g.: "Option < String >" - .split_whitespace() - .collect(); // Remove all whitespace - - if is_compound_field(&field_type) { - let entity_path = extract_compound_entity(&field_type); - *entity_count.entry(entity_path.to_owned()).or_insert(0) += 1; +struct ActiveModelSetter<'a> { + field: &'a Field<'a>, + fields: &'a Fields<'a>, +} + +impl<'a> ActiveModelSetter<'a> { + fn expand(&self) -> syn::Result { + let field_ident = self.field.ident; + match &self.field.kind { + FieldKind::Ignored => Ok(quote!()), + FieldKind::Scalar(scalar) => { + let field_type = scalar.ty; + let setter = format_ident!("set_{}", field_ident); + + Ok(quote! { + #[doc = " Generated by sea-orm-macros"] + pub fn #setter(mut self, v: impl Into<#field_type>) -> Self { + self.#field_ident = sea_orm::Set(v.into()); + self } - } + }) + } + FieldKind::Compound(compound_field) => { + self.expand_compound(&compound_field.compound, None) + } + FieldKind::Relation(relation_field) => { + self.expand_compound(&relation_field.compound, Some(&relation_field.relation)) } } } - if let Data::Struct(item_struct) = &data { - if let Fields::Named(fields) = &item_struct.fields { - for field in fields.named.iter() { - if let Some(ident) = &field.ident { - if field_not_ignored_compound(field) { - let field_type = &field.ty; - let field_type: String = quote! { #field_type } - .to_string() // e.g.: "Option < String >" - .split_whitespace() - .collect(); // Remove all whitespace - - let ty = if is_compound_field(&field_type) { - compound_fields.push(ident); - let entity_path = extract_compound_entity(&field_type); - - if !compact { - let compound_attrs = - compound_attr::SeaOrm::from_attributes(&field.attrs)?; - if let Some(relation_enum) = compound_attrs.relation_enum { - if compound_attrs.self_ref.is_some() - && compound_attrs.via.is_none() - && compound_attrs.from.is_some() - && compound_attrs.to.is_some() - { - belongs_to_self_fields - .push((ident.clone(), relation_enum.clone())); - } else if compound_attrs.self_ref.is_some() - && compound_attrs.relation_reverse.is_some() - && compound_attrs.via.is_none() - && compound_attrs.from.is_none() - && compound_attrs.to.is_none() - { - has_many_self_fields - .push((ident.clone(), relation_enum.clone())); - } else if compound_attrs.belongs_to.is_some() { - // A belongs_to sharing its target entity with another - // relation, disambiguated by an explicit relation_enum: - // key the FK write by that relation variant. - let variant = Ident::new( - &relation_enum.value().to_upper_camel_case(), - relation_enum.span(), - ); - belongs_to_fields.push(( - ident.clone(), - entity_path.to_owned(), - Some(variant), - )); - } - } else if *entity_count.get(entity_path).unwrap() == 1 { - // can only Related to another Entity once - if compound_attrs.belongs_to.is_some() { - // Unique target: key the FK write by entity. - belongs_to_fields.push(( - ident.clone(), - entity_path.to_owned(), - None, - )); - } else if compound_attrs.has_one.is_some() { - has_one_fields.push(ident.clone()); - } else if compound_attrs.has_many.is_some() - && compound_attrs.via.is_none() - { - has_many_fields.push(ident.clone()); - } else if compound_attrs.has_many.is_some() - && compound_attrs.via.is_some() - { - has_many_via_fields.push(( - ident.clone(), - compound_attrs.via.as_ref().unwrap().value(), - )); - } - } else if compound_attrs.belongs_to.is_some() { - // A belongs_to sharing its target entity with another - // relation but without an explicit relation_enum: key the - // FK write by the inferred (default) relation variant. - let variant = Ident::new( - &infer_relation_name_from_entity(entity_path) - .to_upper_camel_case(), - Span::call_site(), - ); - belongs_to_fields.push(( - ident.clone(), - entity_path.to_owned(), - Some(variant), - )); - } - if compound_attrs.self_ref.is_some() - && compound_attrs.via.is_some() - && compound_attrs.reverse.is_none() - { - #[allow(clippy::unnecessary_unwrap)] - has_many_via_self_fields.push(( - ident.clone(), - compound_attrs.via.as_ref().unwrap().value(), - false, - )); - } else if compound_attrs.self_ref.is_some() - && compound_attrs.via.is_some() - && compound_attrs.reverse.is_some() - { - #[allow(clippy::unnecessary_unwrap)] - has_many_via_self_fields.push(( - ident.clone(), - compound_attrs.via.as_ref().unwrap().value(), - true, - )); - } + fn expand_compound( + &self, + compound: &CompoundType, + relation: Option<&RelationAttr>, + ) -> syn::Result { + let field_ident = self.field.ident; + let entity_path = &compound.entity; + let mut active_model_type = entity_path.path.clone(); + let Some(segment) = active_model_type.segments.last_mut() else { + return Err(syn::Error::new_spanned(entity_path, "expected entity path")); + }; + segment.ident = format_ident!("ActiveModelEx"); + segment.arguments = PathArguments::None; + + match compound.kind { + CompoundKind::BelongsTo(cardinality) => { + let (target_entity, optional) = match cardinality { + CardinalityKind::Required => (quote!(#entity_path), quote!()), + CardinalityKind::Optional => (quote!(Option<#entity_path>), quote!(Some)), + }; + let setter = format_ident!("set_{}", field_ident); + let optional_setters = if matches!(cardinality, CardinalityKind::Optional) { + let optional_setter = format_ident!("set_{}_option", field_ident); + let clear_method = format_ident!("clear_{}", field_ident); + let clear_parent_keys = self.clear_parent_key_setters(relation)?; + + quote! { + #[doc = " Generated by sea-orm-macros"] + pub fn #optional_setter(mut self, v: Option>) -> Self { + if v.is_none() { + #clear_parent_keys } + self.#field_ident = sea_orm::ActiveBelongsTo::<#target_entity>::set(v); + self + } - if field_type.starts_with("HasOne<") { - syn::parse_str(&format!("ActiveHasOne < {entity_path} >"))? - } else { - syn::parse_str(&format!("ActiveHasMany < {entity_path} >"))? - } - } else { - scalar_fields.push(ident); - syn::parse_str(&format!("sea_orm::ActiveValue < {field_type} >"))? - }; - model_fields.push(ident); - field_types.push(ty); - } else { - ignored_model_fields.push(ident); + #[doc = " Generated by sea-orm-macros"] + pub fn #clear_method(mut self) -> Self { + #clear_parent_keys + self.#field_ident = sea_orm::ActiveBelongsTo::Set(None); + self + } } - } + } else { + quote!() + }; + + Ok(quote! { + #[doc = " Generated by sea-orm-macros"] + pub fn #setter(mut self, v: impl Into<#active_model_type>) -> Self { + self.#field_ident = sea_orm::ActiveBelongsTo::<#target_entity>::set(#optional(v.into())); + self + } + + #optional_setters + }) + } + CompoundKind::HasOne => { + let setter = format_ident!("set_{}", field_ident); + let optional_setter = format_ident!("set_{}_option", field_ident); + let clear_method = format_ident!("clear_{}", field_ident); + + Ok(quote! { + #[doc = " Generated by sea-orm-macros"] + pub fn #setter(mut self, v: impl Into<#active_model_type>) -> Self { + self.#field_ident = sea_orm::ActiveHasOne::<#entity_path>::set(Some(v.into())); + self + } + + #[doc = " Generated by sea-orm-macros"] + pub fn #optional_setter(mut self, v: Option>) -> Self { + self.#field_ident = sea_orm::ActiveHasOne::<#entity_path>::set(v); + self + } + + #[doc = " Generated by sea-orm-macros"] + pub fn #clear_method(mut self) -> Self { + self.#field_ident = sea_orm::ActiveHasOne::Set(None); + self + } + + }) + } + CompoundKind::HasMany => { + let setter = format_ident!( + "add_{}", + pluralizer::pluralize(&field_ident.to_string(), 1, false) + ); + + Ok(quote! { + #[doc = " Generated by sea-orm-macros"] + pub fn #setter(mut self, v: impl Into<#active_model_type>) -> Self { + self.#field_ident.push(v.into()); + self + } + }) } } } - let active_model_trait_methods = - DeriveActiveModel::new(vis, ident, data)?.impl_active_model_trait_methods(); + fn clear_parent_key_setters( + &self, + relation: Option<&RelationAttr>, + ) -> syn::Result { + let from = match relation { + Some( + RelationAttr::BelongsTo { from, .. } | RelationAttr::BelongsToSelf { from, .. }, + ) => from, + _ => return Ok(quote!()), + }; + let nullable_from_fields = self.fields.get_nullable_from_fields(from)?; + + Ok(quote! { + #(self.#nullable_from_fields = sea_orm::Set(None);)* + }) + } +} + +enum Relation<'a> { + BelongsTo(BelongsToField<'a>), + BelongsToSelf(BelongsToSelfField<'a>), + BelongsToLegacy(LegacyBelongsToField<'a>), + HasOne(HasOneField<'a>), + HasMany(HasManyField<'a>), + HasManySelf(SelfRelationField<'a>), + HasManyVia(ViaField<'a>), + HasManyViaSelf(SelfViaField<'a>), +} + +#[derive(Default)] +struct ActiveModelActionTokens { + belongs_to_action: TokenStream, + belongs_to_after_action: TokenStream, + has_one_before_action: TokenStream, + has_one_action: TokenStream, + has_one_delete: TokenStream, + has_many_before_action: TokenStream, + has_many_action: TokenStream, + has_many_delete: TokenStream, + has_many_via_before_action: TokenStream, + has_many_via_action: TokenStream, + has_many_via_delete: TokenStream, +} + +impl ActiveModelActionTokens { + fn from_fields(fields: &Fields<'_>) -> syn::Result { + let relations = fields + .0 + .iter() + .filter_map(|field| { + let FieldKind::Relation(relation_field) = &field.kind else { + return None; + }; + Some((field.ident, relation_field)) + }) + .try_fold(Vec::new(), |mut relations, (ident, relation_field)| { + let compound = &relation_field.compound; + let relation_attr = &relation_field.relation; + let is_unique_entity = fields.entity_count(&compound.entity) == 1; + + let relation = match relation_attr { + RelationAttr::BelongsToSelf { + relation_enum, + cardinality, + from, + } => Some(Relation::BelongsToSelf(BelongsToSelfField { + ident, + relation_enum: relation_enum.clone(), + cardinality: *cardinality, + nullable_from_fields: fields.get_nullable_from_fields(from)?, + })), + RelationAttr::HasManySelf { relation_enum } => { + Some(Relation::HasManySelf(SelfRelationField { + ident, + relation_enum: relation_enum.clone(), + })) + } + RelationAttr::BelongsTo { + cardinality, + relation_enum, + from, + } => { + // Always generate. Pick the FK-write path: an explicit + // relation_enum, or a non-unique target, is keyed by relation + // variant (there is no canonical `Related` to key by); + // a unique target with no explicit enum uses the entity-keyed path. + let relation_variant = match (relation_enum.as_ref(), is_unique_entity) { + (Some(relation_enum), _) => Some(Ident::new( + &relation_enum.value().to_upper_camel_case(), + relation_enum.span(), + )), + (None, false) => Some(Ident::new( + &infer_relation_name_from_entity(&compound.entity) + .to_upper_camel_case(), + Span::call_site(), + )), + (None, true) => None, + }; + Some(Relation::BelongsTo(BelongsToField { + ident, + entity: &compound.entity, + cardinality: *cardinality, + relation_enum: relation_variant, + nullable_from_fields: fields.get_nullable_from_fields(from)?, + })) + } + RelationAttr::BelongsToLegacy { relation_enum } => { + // Same FK-write keying as `BelongsTo`: explicit relation_enum, or a + // non-unique target, is keyed by relation variant; a unique target + // uses the entity-keyed path. + let relation_variant = match (relation_enum.as_ref(), is_unique_entity) { + (Some(relation_enum), _) => Some(Ident::new( + &relation_enum.value().to_upper_camel_case(), + relation_enum.span(), + )), + (None, false) => Some(Ident::new( + &infer_relation_name_from_entity(&compound.entity) + .to_upper_camel_case(), + Span::call_site(), + )), + (None, true) => None, + }; + Some(Relation::BelongsToLegacy(LegacyBelongsToField { + ident, + relation_enum: relation_variant, + })) + } + RelationAttr::BelongsToSelfLegacy { relation_enum } => { + Some(Relation::BelongsToLegacy(LegacyBelongsToField { + ident, + relation_enum: Some(Ident::new( + &relation_enum.value().to_upper_camel_case(), + relation_enum.span(), + )), + })) + } + RelationAttr::HasOne if is_unique_entity => { + Some(Relation::HasOne(HasOneField { + ident, + entity: &compound.entity, + })) + } + RelationAttr::HasMany if is_unique_entity => { + Some(Relation::HasMany(HasManyField { + ident, + entity: &compound.entity, + })) + } + RelationAttr::HasManyVia { via } if is_unique_entity => { + Some(Relation::HasManyVia(ViaField { + ident, + entity: via.value(), + })) + } + RelationAttr::HasManyViaSelf { via, reverse } => { + Some(Relation::HasManyViaSelf(SelfViaField { + ident, + entity: via.value(), + reverse: *reverse, + })) + } + _ => None, + }; + + relations.extend(relation); + Ok::<_, syn::Error>(relations) + })?; + + let mut this = Self::default(); + for relation in &relations { + relation.expand_into(&mut this); + } + + Ok(this) + } + + fn expand(self) -> TokenStream { + let async_ = async_token(); + let await_ = await_token(); + let Self { + belongs_to_action, + belongs_to_after_action, + has_one_before_action, + has_one_action, + has_one_delete, + has_many_before_action, + has_many_action, + has_many_delete, + has_many_via_before_action, + has_many_via_action, + has_many_via_delete, + } = self; + + quote! { + #[doc = " Generated by sea-orm-macros"] + pub #async_ fn insert<'a, C>(self, db: &'a C) -> Result + where + C: sea_orm::TransactionTrait, + { + let active_model = self.action(sea_orm::ActiveModelAction::Insert, db)#await_?; + active_model.try_into() + } + + #[doc = " Generated by sea-orm-macros"] + pub #async_ fn update<'a, C>(self, db: &'a C) -> Result + where + C: sea_orm::TransactionTrait, + { + let active_model = self.action(sea_orm::ActiveModelAction::Update, db)#await_?; + active_model.try_into() + } + + #[doc = " Generated by sea-orm-macros"] + pub #async_ fn save<'a, C>(self, db: &'a C) -> Result + where + C: sea_orm::TransactionTrait, + { + self.action(sea_orm::ActiveModelAction::Save, db)#await_ + } + + #[doc = " Generated by sea-orm-macros"] + pub #async_ fn delete<'a, C>(self, db: &'a C) -> Result + where + C: sea_orm::TransactionTrait, + { + use sea_orm::{IntoActiveModel, TransactionSession}; - let active_model_action = expand_active_model_action( - &belongs_to_fields, - &belongs_to_self_fields, - &has_one_fields, - &has_many_fields, - &has_many_self_fields, - &has_many_via_fields, - &has_many_via_self_fields, - ); + let txn = db.begin()#await_?; + let db = &txn; + let mut deleted = sea_orm::DeleteResult::empty(); - let active_model_setters = expand_active_model_setters(data)?; + #has_one_delete + #has_many_delete + #has_many_via_delete - let mut is_changed_expr = quote!(false); + let model: ActiveModel = self.into(); + deleted.merge(model.delete(db)#await_?); - for field in scalar_fields.iter() { - is_changed_expr.extend(quote!(|| self.#field.is_set())); + txn.commit()#await_?; + + Ok(deleted) + } + + #[doc = " Generated by sea-orm-macros"] + pub #async_ fn action<'a, C>(mut self, action: sea_orm::ActiveModelAction, db: &'a C) -> Result + where + C: sea_orm::TransactionTrait, + { + use sea_orm::{ActiveBelongsTo, ActiveHasOne, ActiveHasMany, IntoActiveModel, TransactionSession}; + let txn = db.begin()#await_?; + let db = &txn; + let mut deleted = sea_orm::DeleteResult::empty(); + + #belongs_to_action + #has_one_before_action + #has_many_before_action + #has_many_via_before_action + + let model: ActiveModel = self.into(); + + // A genuinely new row (no primary key set yet) must reach the database even + // when no column was explicitly `Set` — e.g. a row with only an auto-increment + // primary key — so the generated key is returned. An unchanged model that + // already has a primary key (e.g. re-saving an existing parent only to attach + // relations) is left untouched, as before, so it is not re-inserted. + let will_insert = !model.is_update() + && matches!( + action, + sea_orm::ActiveModelAction::Insert | sea_orm::ActiveModelAction::Save + ); + + let mut model: Self = if model.is_changed() || will_insert { + match action { + sea_orm::ActiveModelAction::Insert => model.insert(db)#await_, + sea_orm::ActiveModelAction::Update => model.update(db)#await_, + sea_orm::ActiveModelAction::Save => if !model.is_update() { + model.insert(db)#await_ + } else { + model.update(db)#await_ + }, + }?.into_ex().into() + } else { + model.into() + }; + + #belongs_to_after_action + #has_one_action + #has_many_action + #has_many_via_action + + txn.commit()#await_?; + + Ok(model) + } + } } - for field in compound_fields.iter() { - is_changed_expr.extend(quote!(|| self.#field.is_changed())); +} + +#[derive(Default)] +struct Output<'a> { + model_field_defs: Vec, + active_model_setters: TokenStream, + ignored_model_fields: Vec<&'a Ident>, + scalar_fields: Vec<&'a Ident>, + compound_fields: Vec<&'a Ident>, +} + +impl<'a> Output<'a> { + fn from_fields(fields: &'a Fields<'a>) -> syn::Result { + let mut this = Self::default(); + + for field in &fields.0 { + field.expand_into(fields, &mut this)?; + } + + Ok(this) } +} + +fn expand_active_model_ex<'a>( + vis: &Visibility, + ident: &Ident, + data: &Data, + fields: &'a Fields<'a>, + active_model_action: TokenStream, +) -> syn::Result { + let async_ = async_token(); + let await_ = await_token(); + let active_model_trait_methods = + DeriveActiveModel::new(vis, ident, data)?.impl_active_model_trait_methods(); + let Output { + model_field_defs, + active_model_setters, + ignored_model_fields, + scalar_fields, + compound_fields, + } = Output::from_fields(fields)?; Ok(quote! { #[doc = " Generated by sea-orm-macros"] #[derive(Clone, Debug, PartialEq)] #vis struct ActiveModelEx { - #( - #[doc = " Generated by sea-orm-macros"] - pub #model_fields: #field_types - ),* + #(#model_field_defs),* } impl ActiveModel { @@ -243,7 +950,7 @@ pub fn expand_derive_active_model_ex( /// Returns true if any field is set or changed. This is recursive. fn is_changed(&self) -> bool { - #is_changed_expr + false #(|| self.#scalar_fields.is_set())* #(|| self.#compound_fields.is_changed())* } fn default() -> Self { @@ -359,200 +1066,363 @@ pub fn expand_derive_active_model_ex( }) } -fn expand_active_model_action( - belongs_to: &[(Ident, String, Option)], - belongs_to_self: &[(Ident, LitStr)], - has_one: &[Ident], - has_many: &[Ident], - has_many_self: &[(Ident, LitStr)], - has_many_via: &[(Ident, String)], - has_many_via_self: &[(Ident, String, bool)], -) -> TokenStream { - let mut belongs_to_action = TokenStream::new(); - let mut belongs_to_after_action = TokenStream::new(); - let mut has_one_before_action = TokenStream::new(); - let mut has_one_action = TokenStream::new(); - let mut has_one_delete = TokenStream::new(); - let mut has_many_before_action = TokenStream::new(); - let mut has_many_action = TokenStream::new(); - let mut has_many_delete = TokenStream::new(); - let mut has_many_via_before_action = TokenStream::new(); - let mut has_many_via_action = TokenStream::new(); - let mut has_many_via_delete = TokenStream::new(); - - let (async_, await_) = async_await(); - let box_pin = if cfg!(feature = "async") { - quote!(Box::pin) - } else { - quote!() - }; +struct BelongsToField<'a> { + ident: &'a Ident, + entity: &'a TypePath, + cardinality: CardinalityKind, + nullable_from_fields: Vec<&'a Ident>, + /// The relation-enum variant to key FK writes by (`*_parent_key_for`) when this + /// relation shares its target entity with another. `None` uses the entity-keyed + /// path (`*_parent_key`), valid only when the target has a unique `Related`. + relation_enum: Option, +} - for (field, related_entity, relation_variant) in belongs_to { - let related_entity: TokenStream = related_entity.parse().unwrap(); - // Disambiguate the FK write. A relation sharing its target entity with another - // is keyed by its relation variant (there is no unique `Related` to key by); - // a unique target is keyed by entity. - let (set_parent_key, clear_parent_key) = match relation_variant { - Some(variant) => ( - quote!(self.set_parent_key_for(&model, Relation::#variant)?), - quote!(self.clear_parent_key_for(Relation::#variant)?), +impl BelongsToField<'_> { + fn belongs_to_action(&self) -> TokenStream { + let await_ = await_token(); + let box_pin = if cfg!(feature = "async") { + quote!(Box::pin) + } else { + quote!() + }; + let ident = self.ident; + let related_entity = self.entity; + let (belongs_to_target, optional) = match self.cardinality { + CardinalityKind::Required => (quote!(#related_entity), quote!()), + CardinalityKind::Optional => (quote!(Option<#related_entity>), quote!(Some)), + }; + let nullable_from_fields = &self.nullable_from_fields; + // Disambiguate the FK write: a relation that shares its target entity with + // another relation is keyed by its relation enum; otherwise by entity (the + // canonical `Related`). + let (set_parent_key, clear_parent_key) = match &self.relation_enum { + Some(relation_enum) => ( + quote!(self.set_parent_key_for(&model, Relation::#relation_enum)?), + quote!(self.clear_parent_key_for(Relation::#relation_enum)?), ), None => ( quote!(self.set_parent_key(&model)?), quote!(self.clear_parent_key::<#related_entity>()?), ), }; - belongs_to_action.extend(quote! { - // Detach: `Delete` on a belongs_to nulls this row's own foreign key. - // If the FK is not nullable it cannot be detached — return a clean error - // instead of a raw database constraint violation. - if self.#field.is_delete() && !#clear_parent_key { - return Err(sea_orm::DbErr::Type(format!( - "Relation `{}` cannot be detached: its foreign key is not nullable", - stringify!(#field) - ))); - } - let #field = if let Some(model) = self.#field.take() { + let replace_model = quote! { + ActiveBelongsTo::Set(#optional(model)) => { + let mut model = *model; if model.is_update() { - // has primary key #set_parent_key; if model.is_changed() { - let model = #box_pin(model.action(action, db))#await_?; - Some(model) - } else { - Some(model) + model = #box_pin(model.action(action, db))#await_?; } } else { - // new model - let model = #box_pin(model.action(action, db))#await_?; + model = #box_pin(model.action(action, db))#await_?; #set_parent_key; - Some(model) } - } else { - None + ActiveBelongsTo::<#belongs_to_target>::set(#optional(model)) + } + }; + let clear_optional = if matches!(self.cardinality, CardinalityKind::Optional) { + quote! { + ActiveBelongsTo::Set(None) => { + #(self.#nullable_from_fields = sea_orm::Set(None);)* + if !#clear_parent_key { + return Err(sea_orm::DbErr::Type(format!( + "Relation {} cannot be cleared", + stringify!(#ident) + ))); + } + ActiveBelongsTo::Set(None) + } + } + } else { + quote!() + }; + + quote! { + let #ident = match self.#ident.take() { + ActiveBelongsTo::NotSet => ActiveBelongsTo::NotSet, + #replace_model + #clear_optional }; - }); + } + } - belongs_to_after_action.extend(quote! { - if let Some(#field) = #field { - model.#field = ActiveHasOne::set(#field); + fn belongs_to_after_action(&self) -> TokenStream { + let ident = self.ident; + quote! { + if #ident.is_set() { + model.#ident = #ident; } - }); + } } - for (field, relation_enum) in belongs_to_self { - let relation_enum = Ident::new(&relation_enum.value(), relation_enum.span()); - let relation_enum = quote!(Relation::#relation_enum); + fn expand_into(&self, output: &mut ActiveModelActionTokens) { + output.belongs_to_action.extend(self.belongs_to_action()); + output + .belongs_to_after_action + .extend(self.belongs_to_after_action()); + } +} - // belongs to is the exception where action is performed before self - belongs_to_action.extend(quote! { - let #field = if let Some(model) = self.#field.take() { - if model.is_update() { - // has primary key - self.set_parent_key_for(&model, #relation_enum)?; - if model.is_changed() { - let model = #box_pin(model.action(action, db))#await_?; - Some(model) +/// Legacy `belongs_to` whose field type is `HasOne` (wrapped by `ActiveHasOne`), kept +/// for backward compatibility. Writes this row's own foreign key from the related model, +/// mirroring the pre-`BelongsTo` behavior. Setting the relation to `Set(None)` leaves the +/// foreign key untouched (the legacy no-op); use `BelongsTo>` for clear/detach. +struct LegacyBelongsToField<'a> { + ident: &'a Ident, + /// Key the FK write by relation variant (`set_parent_key_for`) when the target entity + /// is shared with another relation; `None` uses the entity-keyed path (`set_parent_key`). + relation_enum: Option, +} + +impl LegacyBelongsToField<'_> { + fn belongs_to_action(&self) -> TokenStream { + let await_ = await_token(); + let box_pin = if cfg!(feature = "async") { + quote!(Box::pin) + } else { + quote!() + }; + let ident = self.ident; + let set_parent_key = match &self.relation_enum { + Some(relation_enum) => { + quote!(self.set_parent_key_for(&model, Relation::#relation_enum)?) + } + None => quote!(self.set_parent_key(&model)?), + }; + + quote! { + let #ident = match std::mem::take(&mut self.#ident) { + ActiveHasOne::Set(Some(model)) => { + let mut model = *model; + if model.is_update() { + #set_parent_key; + if model.is_changed() { + model = #box_pin(model.action(action, db))#await_?; + } } else { - Some(model) + model = #box_pin(model.action(action, db))#await_?; + #set_parent_key; } - } else { - // new model - let model = #box_pin(model.action(action, db))#await_?; - self.set_parent_key_for(&model, #relation_enum)?; Some(model) } - } else { - None + // `NotSet` or `Set(None)`: leave the foreign key as-is (legacy no-op). + ActiveHasOne::NotSet | ActiveHasOne::Set(None) => None, }; - }); + } + } - belongs_to_after_action.extend(quote! { - if let Some(#field) = #field { - model.#field = ActiveHasOne::set(#field); + fn belongs_to_after_action(&self) -> TokenStream { + let ident = self.ident; + quote! { + if let Some(#ident) = #ident { + model.#ident = ActiveHasOne::set(Some(#ident)); } - }); + } } - let delete_associated_model = quote! { - let mut item = item.into_active_model(); - if item.clear_parent_key::()? { - item.update(db)#await_?; - } else { - deleted.merge(item.into_ex().delete(db)#await_?); // deep delete + fn expand_into(&self, output: &mut ActiveModelActionTokens) { + output.belongs_to_action.extend(self.belongs_to_action()); + output + .belongs_to_after_action + .extend(self.belongs_to_after_action()); + } +} + +struct HasOneField<'a> { + ident: &'a Ident, + entity: &'a TypePath, +} + +impl HasOneField<'_> { + fn has_one_before_action(&self) -> TokenStream { + let ident = self.ident; + quote! { + let #ident = std::mem::take(&mut self.#ident); } - }; + } - for field in has_one { - has_one_before_action.extend(quote! { - let #field = std::mem::take(&mut self.#field); - }); + fn has_one_action(&self) -> TokenStream { + let await_ = await_token(); + let box_pin = if cfg!(feature = "async") { + quote!(Box::pin) + } else { + quote!() + }; + let delete_associated_model = quote! { + let mut item = item.into_active_model(); + if item.clear_parent_key::()? { + item.update(db)#await_?; + } else { + deleted.merge(item.into_ex().delete(db)#await_?); // deep delete + } + }; + let ident = self.ident; + let related_entity = self.entity; + + let delete_existing_child = quote! { + if let Some(item) = model.find_related(#related_entity).one(db)#await_? { + let item_pk = sea_orm::ModelTrait::get_primary_key_value(&item); + let child_pk = sea_orm::ActiveModelTrait::get_primary_key_value(&child); + if child_pk.as_ref() != Some(&item_pk) { + #delete_associated_model + } + } + }; + let set_child_action = quote! { + #delete_existing_child + child.set_parent_key(&model)?; + let child = if child.is_changed() { + #box_pin(child.action(action, db))#await_? + } else { + child + }; + model.#ident = ActiveHasOne::<#related_entity>::set(Some(child)); + }; - has_one_action.extend(quote! { - match #field { + quote! { + match #ident { ActiveHasOne::NotSet => {} - // `Set` (replace) or `Delete`: remove the existing linked record unless it is - // the same one being set. `NotSet` leaves it untouched (non-destructive default). - related => { - if let Some(item) = model.find_related_of(related.as_slice()).one(db)#await_? { - if !related.find(&item) { - #delete_associated_model - } - } - if let Some(mut child) = related.into_option() { - child.set_parent_key(&model)?; - if child.is_changed() { - model.#field = ActiveHasOne::set(#box_pin(child.action(action, db))#await_?); - } else { - model.#field = ActiveHasOne::set(child); - } - } else { - model.#field = ActiveHasOne::NotSet; + ActiveHasOne::Set(Some(child)) => { + let mut child = *child; + #set_child_action + } + ActiveHasOne::Set(None) => { + if let Some(item) = model.find_related(#related_entity).one(db)#await_? { + #delete_associated_model } + model.#ident = ActiveHasOne::Set(None); } } - }); + } + } + + fn has_one_delete(&self) -> TokenStream { + let await_ = await_token(); + let related_entity = self.entity; + let delete_associated_model = quote! { + let mut item = item.into_active_model(); + if item.clear_parent_key::()? { + item.update(db)#await_?; + } else { + deleted.merge(item.into_ex().delete(db)#await_?); // deep delete + } + }; - has_one_delete.extend(quote! { - if let Some(item) = self.find_related_of(self.#field.empty_slice()).one(db)#await_? { + quote! { + if let Some(item) = self.find_related(#related_entity).one(db)#await_? { #delete_associated_model } - }); + } } - for field in has_many { - has_many_before_action.extend(quote! { - let #field = self.#field.take(); - }); + fn expand_into(&self, output: &mut ActiveModelActionTokens) { + output + .has_one_before_action + .extend(self.has_one_before_action()); + output.has_one_action.extend(self.has_one_action()); + output.has_one_delete.extend(self.has_one_delete()); + } +} - has_many_action.extend(quote! { - if #field.is_replace() { - for item in model.find_related_of(#field.as_slice()).all(db)#await_? { - if !#field.find(&item) { - #delete_associated_model +struct BelongsToSelfField<'a> { + ident: &'a Ident, + relation_enum: LitStr, + cardinality: CardinalityKind, + nullable_from_fields: Vec<&'a Ident>, +} + +impl BelongsToSelfField<'_> { + fn belongs_to_action(&self) -> TokenStream { + let await_ = await_token(); + let box_pin = if cfg!(feature = "async") { + quote!(Box::pin) + } else { + quote!() + }; + let ident = self.ident; + let relation_enum = Ident::new(&self.relation_enum.value(), self.relation_enum.span()); + let relation_enum = quote!(Relation::#relation_enum); + let (belongs_to_target, optional) = match self.cardinality { + CardinalityKind::Required => (quote!(Entity), quote!()), + CardinalityKind::Optional => (quote!(Option), quote!(Some)), + }; + let nullable_from_fields = &self.nullable_from_fields; + let clear_parent_key_fields = quote! { + #(self.#nullable_from_fields = sea_orm::Set(None);)* + }; + let replace_model = quote! { + ActiveBelongsTo::Set(#optional(model)) => { + let mut model = *model; + if model.is_update() { + self.set_parent_key_for(&model, #relation_enum)?; + if model.is_changed() { + model = #box_pin(model.action(action, db))#await_?; } + } else { + model = #box_pin(model.action(action, db))#await_?; + self.set_parent_key_for(&model, #relation_enum)?; } + ActiveBelongsTo::<#belongs_to_target>::set(#optional(model)) } - model.#field = #field.empty_holder(); - for mut #field in #field.into_vec() { - #field.set_parent_key(&model)?; - if #field.is_changed() { - model.#field.push(#box_pin(#field.action(action, db))#await_?); - } else { - model.#field.push(#field); + }; + let clear_optional = if matches!(self.cardinality, CardinalityKind::Optional) { + quote! { + ActiveBelongsTo::Set(None) => { + #clear_parent_key_fields + if !self.clear_parent_key_for(#relation_enum)? { + return Err(sea_orm::DbErr::Type(format!( + "Relation {} cannot be cleared", + stringify!(#ident) + ))); + } + ActiveBelongsTo::Set(None) } } - }); + } else { + quote!() + }; - has_many_delete.extend(quote! { - for item in self.find_related_of(self.#field.as_slice()).all(db)#await_? { - #delete_associated_model + quote! { + let #ident = match self.#ident.take() { + ActiveBelongsTo::NotSet => ActiveBelongsTo::NotSet, + #replace_model + #clear_optional + }; + } + } + + fn belongs_to_after_action(&self) -> TokenStream { + let ident = self.ident; + quote! { + if #ident.is_set() { + model.#ident = #ident; } - }); + } } - for (field, relation_enum) in has_many_self { - let relation_enum = Ident::new(&relation_enum.value(), relation_enum.span()); + fn expand_into(&self, output: &mut ActiveModelActionTokens) { + output.belongs_to_action.extend(self.belongs_to_action()); + output + .belongs_to_after_action + .extend(self.belongs_to_after_action()); + } +} + +struct SelfRelationField<'a> { + ident: &'a Ident, + relation_enum: LitStr, +} + +impl SelfRelationField<'_> { + fn expand_into(&self, output: &mut ActiveModelActionTokens) { + let await_ = await_token(); + let box_pin = if cfg!(feature = "async") { + quote!(Box::pin) + } else { + quote!() + }; + let ident = self.ident; + let relation_enum = Ident::new(&self.relation_enum.value(), self.relation_enum.span()); let relation_enum = quote!(Relation::#relation_enum); let delete_associated_model = quote! { @@ -565,317 +1435,284 @@ fn expand_active_model_action( } }; - has_many_before_action.extend(quote! { - let #field = self.#field.take(); - }); + let has_many_before_action = quote! { + let #ident = self.#ident.take(); + }; - has_many_action.extend(quote! { - if #field.is_replace() { + let has_many_action = quote! { + if #ident.is_replace() { for item in model.find_belongs_to_self(#relation_enum, db.get_database_backend())?.all(db)#await_? { - if !#field.find(&item) { + if !#ident.find(&item) { #delete_associated_model } } } - model.#field = #field.empty_holder(); - for mut #field in #field.into_vec() { - #field.set_parent_key_for_self_rev(&model, #relation_enum)?; - if #field.is_changed() { - model.#field.push(#box_pin(#field.action(action, db))#await_?); + model.#ident = #ident.empty_holder(); + for mut #ident in #ident.into_vec() { + #ident.set_parent_key_for_self_rev(&model, #relation_enum)?; + let #ident = if #ident.is_changed() { + #box_pin(#ident.action(action, db))#await_? } else { - model.#field.push(#field); - } + #ident + }; + model.#ident.push(#ident); } - }); + }; - has_many_delete.extend(quote! { + let has_many_delete = quote! { for item in self.find_belongs_to_self(#relation_enum, db.get_database_backend())?.all(db)#await_? { #delete_associated_model } - }); + }; + + output.has_many_before_action.extend(has_many_before_action); + output.has_many_action.extend(has_many_action); + output.has_many_delete.extend(has_many_delete); } +} + +struct ViaField<'a> { + ident: &'a Ident, + entity: String, +} - for (field, via_entity) in has_many_via { - let mut via_entity = via_entity.as_str(); +impl ViaField<'_> { + fn expand_into(&self, output: &mut ActiveModelActionTokens) { + let await_ = await_token(); + let box_pin = if cfg!(feature = "async") { + quote!(Box::pin) + } else { + quote!() + }; + let ident = self.ident; + let mut via_entity = self.entity.as_str(); if let Some((prefix, _)) = via_entity.split_once("::") { via_entity = prefix; } let related_entity: TokenStream = format!("super::{via_entity}::Entity").parse().unwrap(); - has_many_via_before_action.extend(quote! { - let #field = self.#field.take(); - }); + let has_many_via_before_action = quote! { + let #ident = self.#ident.take(); + }; - has_many_via_action.extend(quote! { - model.#field = #field.empty_holder(); - for item in #field.into_vec() { - if item.is_update() { - // has primary key - if item.is_changed() { - model.#field.push(#box_pin(item.action(action, db))#await_?); - } else { - model.#field.push(item); - } + let has_many_via_action = quote! { + model.#ident = #ident.empty_holder(); + for item in #ident.into_vec() { + let item = if item.is_update() && !item.is_changed() { + item } else { - // new model - model.#field.push(#box_pin(item.action(action, db))#await_?); - } + #box_pin(item.action(action, db))#await_? + }; + model.#ident.push(item); } model.establish_links( #related_entity, - model.#field.as_slice(), - model.#field.is_replace(), + model.#ident.as_slice(), + model.#ident.is_replace(), db )#await_?; - }); + }; - has_many_via_delete.extend(quote! { + let has_many_via_delete = quote! { deleted.merge(self.delete_links(#related_entity, db)#await_?); - }); + }; + + output + .has_many_via_before_action + .extend(has_many_via_before_action); + output.has_many_via_action.extend(has_many_via_action); + output.has_many_via_delete.extend(has_many_via_delete); } +} - for (field, via_entity, reverse) in has_many_via_self { - let related_entity: TokenStream = format!("super::{via_entity}::Entity").parse().unwrap(); +struct SelfViaField<'a> { + ident: &'a Ident, + entity: String, + reverse: bool, +} + +impl SelfViaField<'_> { + fn expand_into(&self, output: &mut ActiveModelActionTokens) { + let await_ = await_token(); + let box_pin = if cfg!(feature = "async") { + quote!(Box::pin) + } else { + quote!() + }; + let ident = self.ident; + let related_entity: TokenStream = + format!("super::{}::Entity", self.entity).parse().unwrap(); let establish_links = Ident::new( - if *reverse { + if self.reverse { "establish_links_self_rev" } else { "establish_links_self" }, - field.span(), + ident.span(), ); - has_many_via_before_action.extend(quote! { - let #field = self.#field.take(); - }); + let has_many_via_before_action = quote! { + let #ident = self.#ident.take(); + }; - has_many_via_action.extend(quote! { - model.#field = #field.empty_holder(); - for item in #field.into_vec() { - if item.is_update() { - // has primary key - if item.is_changed() { - model.#field.push(#box_pin(item.action(action, db))#await_?); - } else { - model.#field.push(item); - } + let has_many_via_action = quote! { + model.#ident = #ident.empty_holder(); + for item in #ident.into_vec() { + let item = if item.is_update() && !item.is_changed() { + item } else { - // new model - model.#field.push(#box_pin(item.action(action, db))#await_?); - } + #box_pin(item.action(action, db))#await_? + }; + model.#ident.push(item); } model.#establish_links( #related_entity, - model.#field.as_slice(), - model.#field.is_replace(), + model.#ident.as_slice(), + model.#ident.is_replace(), db )#await_?; - }); + }; - has_many_via_delete.extend(quote! { + let has_many_via_delete = quote! { deleted.merge(self.delete_links_self(#related_entity, db)#await_?); - }); - } + }; - quote! { - #[doc = " Generated by sea-orm-macros"] - pub #async_ fn insert<'a, C>(self, db: &'a C) -> Result - where - C: sea_orm::TransactionTrait, - { - let active_model = self.action(sea_orm::ActiveModelAction::Insert, db)#await_?; - active_model.try_into() - } + output + .has_many_via_before_action + .extend(has_many_via_before_action); + output.has_many_via_action.extend(has_many_via_action); + output.has_many_via_delete.extend(has_many_via_delete); + } +} - #[doc = " Generated by sea-orm-macros"] - pub #async_ fn update<'a, C>(self, db: &'a C) -> Result - where - C: sea_orm::TransactionTrait, - { - let active_model = self.action(sea_orm::ActiveModelAction::Update, db)#await_?; - active_model.try_into() - } +struct HasManyField<'a> { + ident: &'a Ident, + entity: &'a TypePath, +} - #[doc = " Generated by sea-orm-macros"] - pub #async_ fn save<'a, C>(self, db: &'a C) -> Result - where - C: sea_orm::TransactionTrait, - { - self.action(sea_orm::ActiveModelAction::Save, db)#await_ +impl HasManyField<'_> { + fn has_many_before_action(&self) -> TokenStream { + let ident = self.ident; + quote! { + let #ident = self.#ident.take(); } + } - #[doc = " Generated by sea-orm-macros"] - pub #async_ fn delete<'a, C>(self, db: &'a C) -> Result - where - C: sea_orm::TransactionTrait, - { - use sea_orm::{IntoActiveModel, TransactionSession}; - - let txn = db.begin()#await_?; - let db = &txn; - let mut deleted = sea_orm::DeleteResult::empty(); - - #has_one_delete - #has_many_delete - #has_many_via_delete - - let model: ActiveModel = self.into(); - deleted.merge(model.delete(db)#await_?); - - txn.commit()#await_?; - - Ok(deleted) + fn has_many_action(&self) -> TokenStream { + let await_ = await_token(); + let box_pin = if cfg!(feature = "async") { + quote!(Box::pin) + } else { + quote!() + }; + let ident = self.ident; + let related_entity = self.entity; + let delete_associated_model = quote! { + let mut item = item.into_active_model(); + if item.clear_parent_key::()? { + item.update(db)#await_?; + } else { + deleted.merge(item.into_ex().delete(db)#await_?); // deep delete + } + }; + quote! { + if #ident.is_replace() { + for item in model.find_related(#related_entity).all(db)#await_? { + if !#ident.find(&item) { + #delete_associated_model + } + } + } + model.#ident = #ident.empty_holder(); + for mut #ident in #ident.into_vec() { + #ident.set_parent_key(&model)?; + let #ident = if #ident.is_changed() { + #box_pin(#ident.action(action, db))#await_? + } else { + #ident + }; + model.#ident.push(#ident); + } } + } - #[doc = " Generated by sea-orm-macros"] - pub #async_ fn action<'a, C>(mut self, action: sea_orm::ActiveModelAction, db: &'a C) -> Result - where - C: sea_orm::TransactionTrait, - { - use sea_orm::{ActiveHasOne, ActiveHasMany, IntoActiveModel, TransactionSession}; - let txn = db.begin()#await_?; - let db = &txn; - let mut deleted = sea_orm::DeleteResult::empty(); - - #belongs_to_action - #has_one_before_action - #has_many_before_action - #has_many_via_before_action - - let model: ActiveModel = self.into(); - - let mut model: Self = if model.is_changed() { - match action { - sea_orm::ActiveModelAction::Insert => model.insert(db)#await_, - sea_orm::ActiveModelAction::Update => model.update(db)#await_, - sea_orm::ActiveModelAction::Save => if !model.is_update() { - model.insert(db)#await_ - } else { - model.update(db)#await_ - }, - }?.into_ex().into() + fn has_many_delete(&self) -> TokenStream { + let await_ = await_token(); + let related_entity = self.entity; + let delete_associated_model = quote! { + let mut item = item.into_active_model(); + if item.clear_parent_key::()? { + item.update(db)#await_?; } else { - model.into() - }; - - #belongs_to_after_action - #has_one_action - #has_many_action - #has_many_via_action + deleted.merge(item.into_ex().delete(db)#await_?); // deep delete + } + }; + quote! { + for item in self.find_related(#related_entity).all(db)#await_? { + #delete_associated_model + } + } + } - txn.commit()#await_?; + fn expand_into(&self, output: &mut ActiveModelActionTokens) { + output + .has_many_before_action + .extend(self.has_many_before_action()); + output.has_many_action.extend(self.has_many_action()); + output.has_many_delete.extend(self.has_many_delete()); + } +} - Ok(model) +impl Relation<'_> { + fn expand_into(&self, output: &mut ActiveModelActionTokens) { + match self { + Self::BelongsTo(field) => field.expand_into(output), + Self::BelongsToSelf(field) => field.expand_into(output), + Self::BelongsToLegacy(field) => field.expand_into(output), + Self::HasOne(field) => field.expand_into(output), + Self::HasMany(field) => field.expand_into(output), + Self::HasManySelf(field) => field.expand_into(output), + Self::HasManyVia(field) => field.expand_into(output), + Self::HasManyViaSelf(field) => field.expand_into(output), } } } -fn expand_active_model_setters(data: &Data) -> syn::Result { - let mut setters = TokenStream::new(); - - if let Data::Struct(item_struct) = &data { - if let Fields::Named(fields) = &item_struct.fields { - for field in &fields.named { - if let Some(ident) = &field.ident { - let field_type = &field.ty; - let field_type_str: String = quote! { #field_type } - .to_string() // e.g.: "Option < String >" - .split_whitespace() - .collect(); // Remove all whitespace - - let mut ignore = false; - - for attr in field.attrs.iter() { - if attr.path().is_ident("sea_orm") { - attr.parse_nested_meta(|meta| { - if meta.path.is_ident("ignore") { - ignore = true; - } else { - // Reads the value expression to advance the parse stream. - let _: Option = meta.value().and_then(|v| v.parse()).ok(); - } - - Ok(()) - })?; - } - } - - if ignore { - continue; - } - - if is_compound_field(&field_type_str) { - let entity_path = extract_compound_entity(&field_type_str); - let active_model_type: Type = syn::parse_str(&format!( - "{}ActiveModelEx", - entity_path.trim_end_matches("Entity") - ))?; - - if field_type_str.starts_with("HasOne<") { - let setter = format_ident!("set_{}", ident); - let setter_option = format_ident!("set_{}_option", ident); - let deleter = format_ident!("delete_{}", ident); - - setters.extend(quote! { - #[doc = " Generated by sea-orm-macros"] - pub fn #setter(mut self, v: impl Into<#active_model_type>) -> Self { - self.#ident.replace(v.into()); - self - } - - #[doc = " Generated by sea-orm-macros"] - pub fn #setter_option(mut self, v: Option>) -> Self { - self.#ident = match v { - Some(v) => sea_orm::ActiveHasOne::set(v.into()), - None => sea_orm::ActiveHasOne::Delete, - }; - self - } - - #[doc = " Generated by sea-orm-macros"] - pub fn #deleter(mut self) -> Self { - self.#ident = sea_orm::ActiveHasOne::Delete; - self - } - }); - } else { - let setter = format_ident!( - "add_{}", - pluralizer::pluralize(&ident.to_string(), 1, false) - ); - - setters.extend(quote! { - #[doc = " Generated by sea-orm-macros"] - pub fn #setter(mut self, v: impl Into<#active_model_type>) -> Self { - self.#ident.push(v.into()); - self - } - }); - } - } else { - let setter = format_ident!("set_{}", ident); +pub fn expand_derive_active_model_ex( + vis: &Visibility, + ident: &Ident, + data: &Data, + attrs: &[Attribute], +) -> syn::Result { + let mut compact = false; - setters.extend(quote! { - #[doc = " Generated by sea-orm-macros"] - pub fn #setter(mut self, v: impl Into<#field_type>) -> Self { - self.#ident = sea_orm::Set(v.into()); - self - } - }); - } + attrs + .iter() + .filter(|attr| attr.path().is_ident("sea_orm")) + .try_for_each(|attr| { + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("compact_model") { + compact = true; + } else { + consume_meta(meta); } - } - } - } - - Ok(setters) -} + Ok(()) + }) + })?; -fn async_await() -> (TokenStream, TokenStream) { - if cfg!(feature = "async") { - (quote!(async), quote!(.await)) + let mode = if compact { + FieldParseMode::Compact } else { - (quote!(), quote!()) - } + FieldParseMode::Dense + }; + let fields = Fields::from_data(data, ident, mode)?; + let active_model_action_tokens = if compact { + ActiveModelActionTokens::default() + } else { + ActiveModelActionTokens::from_fields(&fields)? + }; + let active_model_action = active_model_action_tokens.expand(); + + expand_active_model_ex(vis, ident, data, &fields, active_model_action) } diff --git a/sea-orm-macros/src/derives/entity_loader.rs b/sea-orm-macros/src/derives/entity_loader.rs index 1d7cd00d80..69f03e8a73 100644 --- a/sea-orm-macros/src/derives/entity_loader.rs +++ b/sea-orm-macros/src/derives/entity_loader.rs @@ -1,460 +1,709 @@ use proc_macro2::TokenStream; use quote::quote; use std::collections::{HashMap, HashSet}; -use syn::{Ident, Visibility, punctuated::Punctuated, token::Comma}; +use syn::{Ident, TypePath, Visibility, punctuated::Punctuated, token::Comma}; #[derive(Default)] pub struct EntityLoaderSchema { pub fields: Vec, } +pub enum EntityLoaderFieldKind { + HasOne, + HasMany { via: Option }, + SelfHasOne, + SelfHasMany, + SelfHasManyVia { via: syn::LitStr, reverse: bool }, +} + pub struct EntityLoaderField { - pub is_one: bool, - pub is_self: bool, - pub is_reverse: bool, pub field: Ident, /// super::bakery::Entity - pub entity: String, + pub entity: TypePath, pub relation_enum: Option, - pub via: Option, + pub kind: EntityLoaderFieldKind, } -fn relation_enum_ident(relation_enum: &syn::LitStr) -> Ident { - Ident::new(&relation_enum.value(), relation_enum.span()) +#[derive(Default)] +struct EntityLoaderOutput { + loader_with_fields: Punctuated, + loader_nest_fields: Punctuated, + select_tuple_fields: Punctuated, + loader_with_set_impl: TokenStream, + loader_with_2_impl: TokenStream, + fetch_select_impl: TokenStream, + assemble_one: TokenStream, + load_one: TokenStream, + load_many: TokenStream, + load_one_nest: TokenStream, + load_many_nest: TokenStream, + load_one_nest_nest: TokenStream, + load_many_nest_nest: TokenStream, + with_param_impls: TokenStream, } -pub fn expand_entity_loader(vis: &Visibility, schema: EntityLoaderSchema) -> TokenStream { - let mut field_bools: Punctuated<_, Comma> = Punctuated::new(); - let mut field_nests: Punctuated<_, Comma> = Punctuated::new(); - let mut one_fields: Punctuated<_, Comma> = Punctuated::new(); - let mut with_impl = TokenStream::new(); - let mut with_nest_impl = TokenStream::new(); - let mut select_impl = TokenStream::new(); - let mut assemble_one = TokenStream::new(); - let mut load_one = TokenStream::new(); - let mut load_many = TokenStream::new(); - let mut load_one_nest = TokenStream::new(); - let mut load_many_nest = TokenStream::new(); - let mut load_one_nest_nest = TokenStream::new(); - let mut load_many_nest_nest = TokenStream::new(); - let mut into_with_param_impl = TokenStream::new(); - let mut relation_tuple_impls = HashSet::new(); - let mut arity = 1; +impl EntityLoaderField { + fn expand_loader_with_field_into(&self, output: &mut EntityLoaderOutput) { + let field = &self.field; - let (async_, await_) = if cfg!(feature = "async") { - (quote!(async), quote!(.await)) - } else { - (quote!(), quote!()) - }; + output.loader_with_fields.push(quote! { + #[doc = " Generated by sea-orm-macros"] + pub #field: bool + }); + } - let async_trait = if cfg!(feature = "async") { - quote!(#[async_trait::async_trait]) - } else { - quote!() - }; + fn expand_loader_nest_field_into(&self, output: &mut EntityLoaderOutput) { + let field = &self.field; + let nest_type = match &self.kind { + EntityLoaderFieldKind::HasOne | EntityLoaderFieldKind::HasMany { .. } => { + let mut entity_module = self.entity.path.clone(); + entity_module.segments.pop(); + entity_module + .segments + .push(syn::parse_quote!(EntityLoaderWith)); + quote!(#entity_module) + } + EntityLoaderFieldKind::SelfHasOne + | EntityLoaderFieldKind::SelfHasMany + | EntityLoaderFieldKind::SelfHasManyVia { .. } => quote!(EntityLoaderWith), + }; + + output.loader_nest_fields.push(quote! { + #[doc = " Generated by sea-orm-macros"] + pub #field: #nest_type + }); + } - one_fields.push(quote!(model)); - - let mut total_count = HashMap::new(); - for entity_field in schema.fields.iter() { - *total_count.entry(&entity_field.entity).or_insert(0) += 1; - } - - for entity_field in schema.fields.iter() { - let field = &entity_field.field; - let is_one = entity_field.is_one; - let is_self = entity_field.is_self; - let is_reverse = entity_field.is_reverse; - let is_duplicate_entity = *total_count.get(&entity_field.entity).unwrap() != 1; - let entity: TokenStream = entity_field.entity.parse().unwrap(); - let entity_module: TokenStream = entity_field - .entity - .trim_end_matches("::Entity") - .parse() - .unwrap(); - - if !is_self { - field_bools.push(quote! { - #[doc = " Generated by sea-orm-macros"] - pub #field: bool - }); - field_nests.push(quote! { - #[doc = " Generated by sea-orm-macros"] - pub #field: #entity_module::EntityLoaderWith - }); - - if !is_duplicate_entity { - with_impl.extend(quote! { - if target == sea_orm::compound::LoadTarget::TableRef(#entity.table_ref()) { - self.#field = true; - } - }); - with_nest_impl.extend(quote! { - if left == sea_orm::compound::LoadTarget::TableRef(#entity.table_ref()) { - self.with.#field = true; - self.nest.#field.set(right); - return self; - } - }); - } else if let Some(relation_enum) = &entity_field.relation_enum { - with_impl.extend(quote! { - if let sea_orm::compound::LoadTarget::Relation(relation_enum) = &target { - if relation_enum == #relation_enum { + fn expand_relation_tuple_with_param_into(&self, output: &mut EntityLoaderOutput) { + let mut entity_module = self.entity.path.clone(); + entity_module.segments.pop(); + let mut related_entity = entity_module.clone(); + related_entity.segments.push(syn::parse_quote!(Entity)); + let mut related_relation = entity_module; + related_relation.segments.push(syn::parse_quote!(Relation)); + + output.with_param_impls.extend(quote! { + impl EntityLoaderWithParam for (Relation, #related_entity) { + fn into_with_param(self) -> (sea_orm::compound::LoadTarget, Option) { + ( + sea_orm::compound::LoadTarget::Relation(sea_orm::RelationTrait::name(&self.0)), + Some(sea_orm::compound::LoadTarget::TableRef(self.1.table_ref())), + ) + } + } + + impl EntityLoaderWithParam for (Relation, #related_relation) { + fn into_with_param(self) -> (sea_orm::compound::LoadTarget, Option) { + ( + sea_orm::compound::LoadTarget::Relation(sea_orm::RelationTrait::name(&self.0)), + Some(sea_orm::compound::LoadTarget::Relation(sea_orm::RelationTrait::name(&self.1))), + ) + } + } + }); + } + + fn expand_self_via_with_param_into( + &self, + output: &mut EntityLoaderOutput, + via: &syn::LitStr, + reverse: bool, + ) { + let via = Ident::new(&via.value(), via.span()); + let target_type = if !reverse { + Ident::new("TableRef", via.span()) + } else { + Ident::new("TableRefRev", via.span()) + }; + + let target_entity = if !reverse { + quote!(super::#via::Entity) + } else { + quote!(super::#via::EntityReverse) + }; + + output.with_param_impls.extend(quote! { + impl EntityLoaderWithParam for #target_entity { + fn into_with_param(self) -> (sea_orm::compound::LoadTarget, Option) { + (sea_orm::compound::LoadTarget::#target_type(super::#via::Entity.table_ref()), None) + } + } + + impl EntityLoaderWithParam for (#target_entity, S) + where + S: EntityTrait, + Entity: Related, + { + fn into_with_param(self) -> (sea_orm::compound::LoadTarget, Option) { + ( + sea_orm::compound::LoadTarget::#target_type(super::#via::Entity.table_ref()), + Some(sea_orm::compound::LoadTarget::TableRef(self.1.table_ref())), + ) + } + } + }); + } + + fn expand_loader_with_set_impl_into( + &self, + output: &mut EntityLoaderOutput, + duplicate_entity: bool, + ) { + let field = &self.field; + let entity = &self.entity; + + match &self.kind { + EntityLoaderFieldKind::HasOne | EntityLoaderFieldKind::HasMany { .. } => { + if !duplicate_entity { + output.loader_with_set_impl.extend(quote! { + if target == sea_orm::compound::LoadTarget::TableRef(#entity.table_ref()) { self.#field = true; } - } - }); - with_nest_impl.extend(quote! { - if let sea_orm::compound::LoadTarget::Relation(relation_enum) = &left { - if relation_enum == #relation_enum { - self.with.#field = true; - self.nest.#field.set(right); - return self; - } - } - }); - - if relation_tuple_impls.insert(entity_field.entity.clone()) { - into_with_param_impl.extend(quote! { - impl EntityLoaderWithParam for (Relation, #entity_module::Entity) { - fn into_with_param(self) -> (sea_orm::compound::LoadTarget, Option) { - ( - sea_orm::compound::LoadTarget::Relation(sea_orm::RelationTrait::name(&self.0)), - Some(sea_orm::compound::LoadTarget::TableRef(self.1.table_ref())), - ) + }); + } else if let Some(relation_enum) = &self.relation_enum { + output.loader_with_set_impl.extend(quote! { + if let sea_orm::compound::LoadTarget::Relation(relation_enum) = &target { + if relation_enum == #relation_enum { + self.#field = true; } } - - impl EntityLoaderWithParam for (Relation, #entity_module::Relation) { - fn into_with_param(self) -> (sea_orm::compound::LoadTarget, Option) { - ( - sea_orm::compound::LoadTarget::Relation(sea_orm::RelationTrait::name(&self.0)), - Some(sea_orm::compound::LoadTarget::Relation(sea_orm::RelationTrait::name(&self.1))), - ) + }); + } + } + EntityLoaderFieldKind::SelfHasOne + | EntityLoaderFieldKind::SelfHasMany + | EntityLoaderFieldKind::SelfHasManyVia { .. } => { + if let Some(relation_enum) = &self.relation_enum { + output.loader_with_set_impl.extend(quote! { + if let sea_orm::compound::LoadTarget::Relation(relation_enum) = &target { + if relation_enum == #relation_enum { + self.#field = true; } } }); } - } - } else { - field_bools.push(quote! { - #[doc = " Generated by sea-orm-macros"] - pub #field: bool - }); - field_nests.push(quote! { - #[doc = " Generated by sea-orm-macros"] - pub #field: EntityLoaderWith - }); - - if let Some(relation_enum) = &entity_field.relation_enum { - with_impl.extend(quote! { - if let sea_orm::compound::LoadTarget::Relation(relation_enum) = &target { - if relation_enum == #relation_enum { + + if let EntityLoaderFieldKind::SelfHasManyVia { via, reverse } = &self.kind { + let via = Ident::new(&via.value(), via.span()); + let target_type = if !reverse { + Ident::new("TableRef", via.span()) + } else { + Ident::new("TableRefRev", via.span()) + }; + + output.loader_with_set_impl.extend(quote! { + if target == sea_orm::compound::LoadTarget::#target_type(super::#via::Entity.table_ref()) { self.#field = true; } - } - }); + }); + } } + } + } - if let Some(via_lit) = &entity_field.via { - let via = Ident::new(&via_lit.value(), via_lit.span()); - let target_type = if !is_reverse { - Ident::new("TableRef", via_lit.span()) - } else { - Ident::new("TableRefRev", via_lit.span()) - }; - - let target_entity = if !is_reverse { - quote!(super::#via::Entity) + fn expand_loader_with_2_impl_into( + &self, + output: &mut EntityLoaderOutput, + duplicate_entity: bool, + ) { + let field = &self.field; + let entity = &self.entity; + + match &self.kind { + EntityLoaderFieldKind::HasOne | EntityLoaderFieldKind::HasMany { .. } => { + if !duplicate_entity { + output.loader_with_2_impl.extend(quote! { + if left == sea_orm::compound::LoadTarget::TableRef(#entity.table_ref()) { + self.with.#field = true; + self.nest.#field.set(right); + return self; + } + }); + } else if let Some(relation_enum) = &self.relation_enum { + output.loader_with_2_impl.extend(quote! { + if let sea_orm::compound::LoadTarget::Relation(relation_enum) = &left { + if relation_enum == #relation_enum { + self.with.#field = true; + self.nest.#field.set(right); + return self; + } + } + }); + } + } + EntityLoaderFieldKind::SelfHasOne | EntityLoaderFieldKind::SelfHasMany => {} + EntityLoaderFieldKind::SelfHasManyVia { via, reverse } => { + let via = Ident::new(&via.value(), via.span()); + let target_type = if !reverse { + Ident::new("TableRef", via.span()) } else { - quote!(super::#via::EntityReverse) + Ident::new("TableRefRev", via.span()) }; - with_impl.extend(quote! { - if target == sea_orm::compound::LoadTarget::#target_type(super::#via::Entity.table_ref()) { - self.#field = true; - } - }); - with_nest_impl.extend(quote! { + output.loader_with_2_impl.extend(quote! { if left == sea_orm::compound::LoadTarget::#target_type(super::#via::Entity.table_ref()) { self.with.#field = true; self.nest.#field.set(right); return self; } }); - - into_with_param_impl.extend(quote! { - impl EntityLoaderWithParam for #target_entity { - fn into_with_param(self) -> (sea_orm::compound::LoadTarget, Option) { - (sea_orm::compound::LoadTarget::#target_type(super::#via::Entity.table_ref()), None) - } - } - - impl EntityLoaderWithParam for (#target_entity, S) - where - S: EntityTrait, - Entity: Related, - { - fn into_with_param(self) -> (sea_orm::compound::LoadTarget, Option) { - ( - sea_orm::compound::LoadTarget::#target_type(super::#via::Entity.table_ref()), - Some(sea_orm::compound::LoadTarget::TableRef(self.1.table_ref())), - ) - } - } - }); } } + } - if is_one && !is_self { - if !is_duplicate_entity { - arity += 1; - if arity <= 3 { - // do not go beyond SelectThree - one_fields.push(quote!(#field)); - - select_impl.extend(quote! { - let select = if self.with.#field && self.nest.#field.is_empty() { - self.with.#field = false; - loaded.#field = true; - select.find_also(Entity, #entity) - } else { - select.select_also_fake(#entity) - }; - }); - - assemble_one.extend(quote! { - if loaded.#field { - model.#field = #field.map(Into::into).map(Box::new).into(); - } - }); - } + fn expand_select_one_into(&self, output: &mut EntityLoaderOutput) { + let field = &self.field; + let entity = &self.entity; - load_one.extend(quote! { - if with.#field { - let #field = models.as_slice().load_one_ex(#entity, db)#await_?; - let #field = #entity_module::EntityLoader::load_nest(#field, &nest.#field, db)#await_?; + output.select_tuple_fields.push(quote!(#field)); + output.fetch_select_impl.extend(quote! { + let select = if self.with.#field && self.nest.#field.is_empty() { + self.with.#field = false; + loaded.#field = true; + select.find_also(Entity, #entity) + } else { + select.select_also_fake(#entity) + }; + }); + output.assemble_one.extend(quote! { + if loaded.#field { + model.#field = #field.map(Into::into).map(Box::new).into(); + } + }); + } - for (model, #field) in models.iter_mut().zip(#field) { - model.#field = #field.map(Into::into).map(Box::new).into(); - } + fn expand_load_one_into(&self, output: &mut EntityLoaderOutput) { + let field = &self.field; + let entity = &self.entity; + let await_ = if cfg!(feature = "async") { + quote!(.await) + } else { + quote!() + }; + let mut entity_module = self.entity.path.clone(); + entity_module.segments.pop(); + entity_module.segments.push(syn::parse_quote!(EntityLoader)); + + output.load_one.extend(quote! { + if with.#field { + let #field = models.as_slice().load_one_ex(#entity, db)#await_?; + let #field = <#entity_module>::load_nest(#field, &nest.#field, db)#await_?; + + for (model, #field) in models.iter_mut().zip(#field) { + model.#field = #field.map(Into::into).map(Box::new).into(); + } + } + }); + output.load_one_nest.extend(quote! { + if with.#field { + let #field = models.as_slice().load_one_ex(#entity, db)#await_?; + + for (model, #field) in models.iter_mut().zip(#field) { + if let Some(model) = model.as_mut() { + model.#field = #field.map(Into::into).map(Box::new).into(); } - }); - load_one_nest.extend(quote! { - if with.#field { - let #field = models.as_slice().load_one_ex(#entity, db)#await_?; - - for (model, #field) in models.iter_mut().zip(#field) { - if let Some(model) = model.as_mut() { - model.#field = #field.map(Into::into).map(Box::new).into(); - } - } + } + } + }); + output.load_one_nest_nest.extend(quote! { + if with.#field { + let #field = models.as_slice().load_one_ex(#entity, db)#await_?; + + for (models, #field) in models.iter_mut().zip(#field) { + for (model, #field) in models.iter_mut().zip(#field) { + model.#field = #field.map(Into::into).map(Box::new).into(); } - }); - load_one_nest_nest.extend(quote! { - if with.#field { - let #field = models.as_slice().load_one_ex(#entity, db)#await_?; + } + } + }); + } - for (models, #field) in models.iter_mut().zip(#field) { - for (model, #field) in models.iter_mut().zip(#field) { - model.#field = #field.map(Into::into).map(Box::new).into(); - } - } - } - }); - } else if let Some(relation_enum) = &entity_field.relation_enum { - let relation_enum = relation_enum_ident(relation_enum); - load_one.extend(quote! { - if with.#field { - let #field = models.as_slice().load_one_ex_with_rel( - #entity, - sea_orm::RelationTrait::def(&Relation::#relation_enum), - db, - )#await_?; - let #field = #entity_module::EntityLoader::load_nest(#field, &nest.#field, db)#await_?; - - for (model, #field) in models.iter_mut().zip(#field) { - model.#field = #field.map(Into::into).map(Box::new).into(); - } - } - }); - load_one_nest.extend(quote! { - if with.#field { - let #field = models.as_slice().load_one_ex_with_rel( - #entity, - sea_orm::RelationTrait::def(&Relation::#relation_enum), - db, - )#await_?; - - for (model, #field) in models.iter_mut().zip(#field) { - if let Some(model) = model.as_mut() { - model.#field = #field.map(Into::into).map(Box::new).into(); - } - } + fn expand_load_one_with_rel_into( + &self, + output: &mut EntityLoaderOutput, + relation_enum: &syn::LitStr, + ) { + let field = &self.field; + let entity = &self.entity; + let relation_enum = Ident::new(&relation_enum.value(), relation_enum.span()); + let await_ = if cfg!(feature = "async") { + quote!(.await) + } else { + quote!() + }; + let mut entity_module = self.entity.path.clone(); + entity_module.segments.pop(); + entity_module.segments.push(syn::parse_quote!(EntityLoader)); + + output.load_one.extend(quote! { + if with.#field { + let #field = models.as_slice().load_one_ex_with_rel( + #entity, + sea_orm::RelationTrait::def(&Relation::#relation_enum), + db, + )#await_?; + let #field = <#entity_module>::load_nest(#field, &nest.#field, db)#await_?; + + for (model, #field) in models.iter_mut().zip(#field) { + model.#field = #field.map(Into::into).map(Box::new).into(); + } + } + }); + output.load_one_nest.extend(quote! { + if with.#field { + let #field = models.as_slice().load_one_ex_with_rel( + #entity, + sea_orm::RelationTrait::def(&Relation::#relation_enum), + db, + )#await_?; + + for (model, #field) in models.iter_mut().zip(#field) { + if let Some(model) = model.as_mut() { + model.#field = #field.map(Into::into).map(Box::new).into(); } - }); - load_one_nest_nest.extend(quote! { - if with.#field { - let #field = models.as_slice().load_one_ex_with_rel( - #entity, - sea_orm::RelationTrait::def(&Relation::#relation_enum), - db, - )#await_?; - - for (models, #field) in models.iter_mut().zip(#field) { - for (model, #field) in models.iter_mut().zip(#field) { - model.#field = #field.map(Into::into).map(Box::new).into(); - } - } + } + } + }); + output.load_one_nest_nest.extend(quote! { + if with.#field { + let #field = models.as_slice().load_one_ex_with_rel( + #entity, + sea_orm::RelationTrait::def(&Relation::#relation_enum), + db, + )#await_?; + + for (models, #field) in models.iter_mut().zip(#field) { + for (model, #field) in models.iter_mut().zip(#field) { + model.#field = #field.map(Into::into).map(Box::new).into(); } - }); + } } - } else if !is_one && !is_self { - if !is_duplicate_entity { - load_many.extend(quote! { - if with.#field { - let #field = models.as_slice().load_many_ex(#entity, db)#await_?; - let #field = #entity_module::EntityLoader::load_nest_nest(#field, &nest.#field, db)#await_?; + }); + } - for (model, #field) in models.iter_mut().zip(#field) { - model.#field = #field.into(); - } + fn expand_load_many_into(&self, output: &mut EntityLoaderOutput) { + let field = &self.field; + let entity = &self.entity; + let await_ = if cfg!(feature = "async") { + quote!(.await) + } else { + quote!() + }; + let mut entity_module = self.entity.path.clone(); + entity_module.segments.pop(); + entity_module.segments.push(syn::parse_quote!(EntityLoader)); + + output.load_many.extend(quote! { + if with.#field { + let #field = models.as_slice().load_many_ex(#entity, db)#await_?; + let #field = <#entity_module>::load_nest_nest(#field, &nest.#field, db)#await_?; + + for (model, #field) in models.iter_mut().zip(#field) { + model.#field = #field.into(); + } + } + }); + output.load_many_nest.extend(quote! { + if with.#field { + let #field = models.as_slice().load_many_ex(#entity, db)#await_?; + + for (model, #field) in models.iter_mut().zip(#field) { + if let Some(model) = model.as_mut() { + model.#field = #field.into(); } - }); - load_many_nest.extend(quote! { - if with.#field { - let #field = models.as_slice().load_many_ex(#entity, db)#await_?; - - for (model, #field) in models.iter_mut().zip(#field) { - if let Some(model) = model.as_mut() { - model.#field = #field.into(); - } - } + } + } + }); + output.load_many_nest_nest.extend(quote! { + if with.#field { + let #field = models.as_slice().load_many_ex(#entity, db)#await_?; + + for (models, #field) in models.iter_mut().zip(#field) { + for (model, #field) in models.iter_mut().zip(#field) { + model.#field = #field.into(); } - }); - load_many_nest_nest.extend(quote! { - if with.#field { - let #field = models.as_slice().load_many_ex(#entity, db)#await_?; + } + } + }); + } - for (models, #field) in models.iter_mut().zip(#field) { - for (model, #field) in models.iter_mut().zip(#field) { - model.#field = #field.into(); - } - } + fn expand_load_many_with_rel_into( + &self, + output: &mut EntityLoaderOutput, + relation_enum: &syn::LitStr, + ) { + let field = &self.field; + let entity = &self.entity; + let relation_enum = Ident::new(&relation_enum.value(), relation_enum.span()); + let await_ = if cfg!(feature = "async") { + quote!(.await) + } else { + quote!() + }; + let mut entity_module = self.entity.path.clone(); + entity_module.segments.pop(); + entity_module.segments.push(syn::parse_quote!(EntityLoader)); + + output.load_many.extend(quote! { + if with.#field { + let #field = models.as_slice().load_many_ex_with_rel( + #entity, + sea_orm::RelationTrait::def(&Relation::#relation_enum), + db, + )#await_?; + let #field = <#entity_module>::load_nest_nest(#field, &nest.#field, db)#await_?; + + for (model, #field) in models.iter_mut().zip(#field) { + model.#field = #field.into(); + } + } + }); + output.load_many_nest.extend(quote! { + if with.#field { + let #field = models.as_slice().load_many_ex_with_rel( + #entity, + sea_orm::RelationTrait::def(&Relation::#relation_enum), + db, + )#await_?; + + for (model, #field) in models.iter_mut().zip(#field) { + if let Some(model) = model.as_mut() { + model.#field = #field.into(); } - }); - } else if entity_field.via.is_none() { - if let Some(relation_enum) = &entity_field.relation_enum { - let relation_enum = relation_enum_ident(relation_enum); - load_many.extend(quote! { - if with.#field { - let #field = models.as_slice().load_many_ex_with_rel( - #entity, - sea_orm::RelationTrait::def(&Relation::#relation_enum), - db, - )#await_?; - let #field = #entity_module::EntityLoader::load_nest_nest(#field, &nest.#field, db)#await_?; - - for (model, #field) in models.iter_mut().zip(#field) { - model.#field = #field.into(); - } + } + } + }); + output.load_many_nest_nest.extend(quote! { + if with.#field { + let #field = models.as_slice().load_many_ex_with_rel( + #entity, + sea_orm::RelationTrait::def(&Relation::#relation_enum), + db, + )#await_?; + + for (models, #field) in models.iter_mut().zip(#field) { + for (model, #field) in models.iter_mut().zip(#field) { + model.#field = #field.into(); } - }); - load_many_nest.extend(quote! { - if with.#field { - let #field = models.as_slice().load_many_ex_with_rel( - #entity, - sea_orm::RelationTrait::def(&Relation::#relation_enum), - db, - )#await_?; - - for (model, #field) in models.iter_mut().zip(#field) { - if let Some(model) = model.as_mut() { - model.#field = #field.into(); - } - } - } - }); - load_many_nest_nest.extend(quote! { - if with.#field { - let #field = models.as_slice().load_many_ex_with_rel( - #entity, - sea_orm::RelationTrait::def(&Relation::#relation_enum), - db, - )#await_?; - - for (models, #field) in models.iter_mut().zip(#field) { - for (model, #field) in models.iter_mut().zip(#field) { - model.#field = #field.into(); - } - } - } - }); } } - } else if is_one && is_self { - if let Some(relation_enum) = &entity_field.relation_enum { - let relation_enum = relation_enum_ident(relation_enum); - load_one.extend(quote! { - if with.#field { - let #field = models.as_slice().load_self_ex(#entity, Relation::#relation_enum, db)#await_?; + }); + } - for (model, #field) in models.iter_mut().zip(#field) { - model.#field = #field.map(Into::into).map(Box::new).into(); - } - } - }); + fn expand_load_self_one_into( + &self, + output: &mut EntityLoaderOutput, + relation_enum: &syn::LitStr, + ) { + let field = &self.field; + let entity = &self.entity; + let relation_enum = Ident::new(&relation_enum.value(), relation_enum.span()); + let await_ = if cfg!(feature = "async") { + quote!(.await) + } else { + quote!() + }; + + output.load_one.extend(quote! { + if with.#field { + let #field = models.as_slice().load_self_ex(#entity, Relation::#relation_enum, db)#await_?; + + for (model, #field) in models.iter_mut().zip(#field) { + model.#field = #field.map(Into::into).map(Box::new).into(); + } } - } else if !is_one && is_self { - if let Some(relation_enum) = &entity_field.relation_enum { - let relation_enum = relation_enum_ident(relation_enum); - load_many.extend(quote! { - if with.#field { - let #field = models.as_slice().load_self_many_ex(#entity, Relation::#relation_enum, db)#await_?; + }); + } - for (model, #field) in models.iter_mut().zip(#field) { - model.#field = #field.into(); - } - } - }); + fn expand_load_self_many_into( + &self, + output: &mut EntityLoaderOutput, + relation_enum: &syn::LitStr, + ) { + let field = &self.field; + let entity = &self.entity; + let relation_enum = Ident::new(&relation_enum.value(), relation_enum.span()); + let await_ = if cfg!(feature = "async") { + quote!(.await) + } else { + quote!() + }; + + output.load_many.extend(quote! { + if with.#field { + let #field = models.as_slice().load_self_many_ex(#entity, Relation::#relation_enum, db)#await_?; + + for (model, #field) in models.iter_mut().zip(#field) { + model.#field = #field.into(); + } } - if let Some(via) = &entity_field.via { - let via = Ident::new(&via.value(), via.span()); - load_many.extend(quote! { - if with.#field { - let #field = models.as_slice().load_self_via_ex(super::#via::Entity, #is_reverse, db)#await_?; - let #field = EntityLoader::load_nest_nest(#field, &nest.#field, db)#await_?; + }); + } - for (model, #field) in models.iter_mut().zip(#field) { - model.#field = #field.into(); - } - } - }); - load_many_nest.extend(quote! { - if with.#field { - let #field = models.as_slice().load_self_via_ex(super::#via::Entity, #is_reverse, db)#await_?; + fn expand_load_self_many_via_into( + &self, + output: &mut EntityLoaderOutput, + via: &syn::LitStr, + reverse: bool, + ) { + let field = &self.field; + let via = Ident::new(&via.value(), via.span()); + let await_ = if cfg!(feature = "async") { + quote!(.await) + } else { + quote!() + }; - for (model, #field) in models.iter_mut().zip(#field) { - if let Some(model) = model.as_mut() { - model.#field = #field.into(); - } - } - } - }); - load_many_nest_nest.extend(quote! { - if with.#field { - let #field = models.as_slice().load_self_via_ex(super::#via::Entity, #is_reverse, db)#await_?; + output.load_many.extend(quote! { + if with.#field { + let #field = models.as_slice().load_self_via_ex(super::#via::Entity, #reverse, db)#await_?; + let #field = EntityLoader::load_nest_nest(#field, &nest.#field, db)#await_?; - for (models, #field) in models.iter_mut().zip(#field) { - for (model, #field) in models.iter_mut().zip(#field) { - model.#field = #field.into(); - } - } + for (model, #field) in models.iter_mut().zip(#field) { + model.#field = #field.into(); + } + } + }); + output.load_many_nest.extend(quote! { + if with.#field { + let #field = models.as_slice().load_self_via_ex(super::#via::Entity, #reverse, db)#await_?; + + for (model, #field) in models.iter_mut().zip(#field) { + if let Some(model) = model.as_mut() { + model.#field = #field.into(); } - }); + } + } + }); + output.load_many_nest_nest.extend(quote! { + if with.#field { + let #field = models.as_slice().load_self_via_ex(super::#via::Entity, #reverse, db)#await_?; + + for (models, #field) in models.iter_mut().zip(#field) { + for (model, #field) in models.iter_mut().zip(#field) { + model.#field = #field.into(); + } + } + } + }); + } + + fn expand_load_into(&self, output: &mut EntityLoaderOutput, duplicate_entity: bool) { + match &self.kind { + EntityLoaderFieldKind::HasOne if !duplicate_entity => { + self.expand_load_one_into(output); + } + EntityLoaderFieldKind::HasOne => { + if let Some(relation_enum) = &self.relation_enum { + self.expand_load_one_with_rel_into(output, relation_enum); + } + } + EntityLoaderFieldKind::HasMany { .. } if !duplicate_entity => { + self.expand_load_many_into(output); + } + EntityLoaderFieldKind::HasMany { via: None } => { + if let Some(relation_enum) = &self.relation_enum { + self.expand_load_many_with_rel_into(output, relation_enum); + } + } + EntityLoaderFieldKind::HasMany { via: Some(_) } => {} + EntityLoaderFieldKind::SelfHasOne => { + if let Some(relation_enum) = &self.relation_enum { + self.expand_load_self_one_into(output, relation_enum); + } + } + EntityLoaderFieldKind::SelfHasMany => { + if let Some(relation_enum) = &self.relation_enum { + self.expand_load_self_many_into(output, relation_enum); + } + } + EntityLoaderFieldKind::SelfHasManyVia { via, reverse } => { + if let Some(relation_enum) = &self.relation_enum { + self.expand_load_self_many_into(output, relation_enum); + } + self.expand_load_self_many_via_into(output, via, *reverse); } } } +} + +impl EntityLoaderOutput { + fn from_schema(schema: &EntityLoaderSchema) -> Self { + let total_count = schema.fields.iter().fold( + HashMap::<&TypePath, usize>::new(), + |mut total_count, field| { + *total_count.entry(&field.entity).or_insert(0) += 1; + total_count + }, + ); + let mut relation_tuple_impls = HashSet::<&TypePath>::new(); + let mut select_arity = 1; + let mut output = Self::default(); + output.select_tuple_fields.push(quote!(model)); + + for field in &schema.fields { + let duplicate_entity = total_count + .get(&field.entity) + .is_some_and(|count| *count != 1); + + field.expand_loader_with_field_into(&mut output); + field.expand_loader_nest_field_into(&mut output); + field.expand_loader_with_set_impl_into(&mut output, duplicate_entity); + field.expand_loader_with_2_impl_into(&mut output, duplicate_entity); + if let EntityLoaderFieldKind::SelfHasManyVia { via, reverse } = &field.kind { + field.expand_self_via_with_param_into(&mut output, via, *reverse); + } + if matches!( + &field.kind, + EntityLoaderFieldKind::HasOne | EntityLoaderFieldKind::HasMany { .. } + ) && duplicate_entity + && field.relation_enum.is_some() + && relation_tuple_impls.insert(&field.entity) + { + field.expand_relation_tuple_with_param_into(&mut output); + } + if matches!(&field.kind, EntityLoaderFieldKind::HasOne) && !duplicate_entity { + select_arity += 1; + if select_arity <= 3 { + field.expand_select_one_into(&mut output); + } + } + field.expand_load_into(&mut output, duplicate_entity); + } + + output + } +} + +pub fn expand_entity_loader(vis: &Visibility, schema: EntityLoaderSchema) -> TokenStream { + let EntityLoaderOutput { + loader_with_fields, + loader_nest_fields, + select_tuple_fields, + loader_with_set_impl, + loader_with_2_impl, + fetch_select_impl, + assemble_one, + load_one, + load_many, + load_one_nest, + load_many_nest, + load_one_nest_nest, + load_many_nest_nest, + with_param_impls, + } = EntityLoaderOutput::from_schema(&schema); + + let (async_, await_) = if cfg!(feature = "async") { + (quote!(async), quote!(.await)) + } else { + (quote!(), quote!()) + }; + + let async_trait = if cfg!(feature = "async") { + quote!(#[async_trait::async_trait]) + } else { + quote!() + }; quote! { @@ -477,13 +726,13 @@ pub fn expand_entity_loader(vis: &Visibility, schema: EntityLoaderSchema) -> Tok #[doc = " Generated by sea-orm-macros"] #[derive(Debug, Default, Clone, PartialEq, Eq)] #vis struct EntityLoaderWith { - #field_bools + #loader_with_fields } #[doc = " Generated by sea-orm-macros"] #[derive(Debug, Default, Clone, PartialEq, Eq)] #vis struct EntityLoaderNest { - #field_nests + #loader_nest_fields } impl Entity { @@ -498,7 +747,7 @@ pub fn expand_entity_loader(vis: &Visibility, schema: EntityLoaderSchema) -> Tok } #[doc = " Generated by sea-orm-macros"] pub fn set(&mut self, target: sea_orm::compound::LoadTarget) { - #with_impl + #loader_with_set_impl } } @@ -575,7 +824,7 @@ pub fn expand_entity_loader(vis: &Visibility, schema: EntityLoaderSchema) -> Tok } } - #into_with_param_impl + #with_param_impls #[automatically_derived] impl std::fmt::Debug for EntityLoader { @@ -662,7 +911,7 @@ pub fn expand_entity_loader(vis: &Visibility, schema: EntityLoaderSchema) -> Tok } fn with_2(mut self, left: sea_orm::compound::LoadTarget, right: sea_orm::compound::LoadTarget) -> Self { - #with_nest_impl + #loader_with_2_impl self } @@ -671,7 +920,7 @@ pub fn expand_entity_loader(vis: &Visibility, schema: EntityLoaderSchema) -> Tok let select = self.select; let mut loaded = EntityLoaderWith::default(); - #select_impl + #fetch_select_impl let models = if page_size != 0 { select.paginate(db, page_size).fetch_page(page)#await_? @@ -679,7 +928,7 @@ pub fn expand_entity_loader(vis: &Visibility, schema: EntityLoaderSchema) -> Tok select.all(db)#await_? }; - let models = models.into_iter().map(|(#one_fields)| { + let models = models.into_iter().map(|(#select_tuple_fields)| { let mut model = model.into_ex(); #assemble_one model diff --git a/sea-orm-macros/src/derives/entity_model.rs b/sea-orm-macros/src/derives/entity_model.rs index a26fea20fd..8a877fa618 100644 --- a/sea-orm-macros/src/derives/entity_model.rs +++ b/sea-orm-macros/src/derives/entity_model.rs @@ -1,12 +1,11 @@ use super::case_style::{CaseStyle, CaseStyleHelpers}; -use super::util::{escape_rust_keyword, trim_starting_raw_identifier}; +use super::util::{consume_meta, escape_rust_keyword, trim_starting_raw_identifier}; use heck::{ ToKebabCase, ToLowerCamelCase, ToShoutySnakeCase, ToSnakeCase, ToTitleCase, ToUpperCamelCase, }; use proc_macro2::{Ident, Span, TokenStream}; use quote::quote; use std::str::FromStr; -use syn::meta::ParseNestedMeta; use syn::{ Attribute, Data, Fields, Lit, LitStr, Visibility, punctuated::Punctuated, spanned::Spanned, token::Comma, @@ -56,10 +55,6 @@ fn serde_deserialize_name( } } -fn consume_meta(meta: ParseNestedMeta<'_>) { - let _ = meta.value().and_then(|v| v.parse::()); -} - /// Method to derive an Model pub fn expand_derive_entity_model( vis: &Visibility, diff --git a/sea-orm-macros/src/derives/model_ex.rs b/sea-orm-macros/src/derives/model_ex.rs index 5a4da085f4..5ba61fab7c 100644 --- a/sea-orm-macros/src/derives/model_ex.rs +++ b/sea-orm-macros/src/derives/model_ex.rs @@ -1,14 +1,18 @@ +use crate::derives::util::consume_meta; + use super::attributes::compound_attr; -use super::entity_loader::{EntityLoaderField, EntityLoaderSchema, expand_entity_loader}; -use super::util::{extract_compound_entity, format_field_ident, is_compound_field}; +use super::entity_loader::{ + EntityLoaderField, EntityLoaderFieldKind, EntityLoaderSchema, expand_entity_loader, +}; +use super::util::{CardinalityKind, CompoundKind, CompoundType, combine_error, is_self_entity}; use super::{expand_typed_column, model::DeriveModel}; use heck::ToUpperCamelCase; use proc_macro2::{Ident, Span, TokenStream}; use quote::{format_ident, quote}; use std::collections::{BTreeMap, HashMap}; use syn::{ - Attribute, Data, Expr, Fields, ItemStruct, Lit, Meta, Type, Visibility, parse_quote, - punctuated::Punctuated, token::Comma, + Attribute, Data, Expr, Field, Fields, ItemStruct, Lit, LitStr, Meta, Type, TypePath, + Visibility, parse_quote, punctuated::Punctuated, token::Comma, }; pub fn expand_sea_orm_model(input: ItemStruct, compact: bool) -> syn::Result { @@ -76,29 +80,34 @@ pub fn expand_sea_orm_model(input: ItemStruct, compact: bool) -> syn::Result = Punctuated::new(); - - list.parse_nested_meta(|meta| { - if meta.path.is_ident("Eq") { - // skip - } else if meta.path.is_ident("DeriveEntityModel") { - // replace macro - new_list.push(parse_quote!(DeriveModelEx)); - new_list.push(parse_quote!(DeriveActiveModelEx)); - } else { - new_list.push(meta.path); - } + if !attr.path().is_ident("derive") { + continue; + } - Ok(()) - })?; + let Meta::List(list) = &mut attr.meta else { + continue; + }; - *attr = parse_quote!(#[derive( #new_list )]); + let mut new_list: Punctuated<_, Comma> = Punctuated::new(); + + list.parse_nested_meta(|meta| { + if meta.path.is_ident("Eq") { + // skip + } else if meta.path.is_ident("DeriveEntityModel") { + // replace macro + new_list.push(parse_quote!(DeriveModelEx)); + new_list.push(parse_quote!(DeriveActiveModelEx)); + } else { + new_list.push(meta.path); } - } + + Ok(()) + })?; + + *attr = parse_quote!(#[derive( #new_list )]); } let compact_model = if compact { @@ -109,19 +118,26 @@ pub fn expand_sea_orm_model(input: ItemStruct, compact: bool) -> syn::Result" - .split_whitespace() - .collect(); // Remove all whitespace - - if is_compound_field(&field_type) { - let entity_path = extract_compound_entity(&field_type); - if field_type.starts_with("Option<") || field_type.starts_with("HasOne<") { - field.ty = syn::parse_str(&format!("HasOne < {entity_path} >"))?; - } else { - field.ty = syn::parse_str(&format!("HasMany < {entity_path} >"))?; + for field in &mut all_fields { + if let Type::Path(type_path) = &field.ty + && let Some(compound) = CompoundType::from_type(type_path)? + { + match compound.kind { + CompoundKind::BelongsTo(cardinality) => { + let entity_type = &compound.entity; + field.ty = match cardinality { + CardinalityKind::Required => parse_quote!(BelongsTo<#entity_type>), + CardinalityKind::Optional => parse_quote!(BelongsTo>), + }; + } + CompoundKind::HasOne => { + let entity_type = &compound.entity; + field.ty = parse_quote!(HasOne<#entity_type>); + } + CompoundKind::HasMany => { + let entity_type = &compound.entity; + field.ty = parse_quote!(HasMany<#entity_type>); + } } } else { model_fields.push(field); @@ -141,148 +157,1084 @@ pub fn expand_sea_orm_model(input: ItemStruct, compact: bool) -> syn::Result, -) -> syn::Result { - let mut compact = false; - let mut model_fields: Vec = Vec::new(); - let mut compound_fields: Vec = Vec::new(); - let mut impl_related = Vec::new(); - let mut entity_loader_schema = EntityLoaderSchema::default(); - let mut unique_keys = BTreeMap::new(); - - attrs - .iter() - .filter(|attr| attr.path().is_ident("sea_orm")) - .try_for_each(|attr| { +struct ScalarField<'a> { + ty: &'a Type, + unique: bool, + unique_keys: Vec, +} + +impl<'a> ScalarField<'a> { + fn from_field(field: &'a Field) -> syn::Result { + let mut unique = false; + let mut unique_keys = Vec::new(); + + for attr in &field.attrs { + if !attr.path().is_ident("sea_orm") { + continue; + } attr.parse_nested_meta(|meta| { - if meta.path.is_ident("compact_model") { - compact = true; + if meta.path.is_ident("unique") { + unique = true; + } + if meta.path.is_ident("unique_key") { + let lit = meta.value()?.parse()?; + if let Lit::Str(litstr) = lit { + unique_keys.push(litstr.parse()?); + } else { + return Err(meta.error(format!("Invalid unique_key {lit:?}"))); + } } else { - // Reads the value expression to advance the parse stream. - let _: Option = meta.value().and_then(|v| v.parse()).ok(); + consume_meta(meta); } Ok(()) + })?; + } + + Ok(Self { + ty: &field.ty, + unique, + unique_keys, + }) + } +} + +struct ModelExField<'a> { + ident: &'a Ident, + kind: ModelExFieldKind<'a>, +} + +enum ModelExFieldKind<'a> { + Scalar(ScalarField<'a>), + Compound(CompoundField), +} + +impl<'a> ModelExField<'a> { + fn from_field(field: &'a Field, compact: bool) -> syn::Result { + let Some(ident) = &field.ident else { + return Err(syn::Error::new_spanned(field, "expected named field")); + }; + let kind = if let Type::Path(type_path) = &field.ty + && let Some(compound) = CompoundType::from_type(type_path)? + { + ModelExFieldKind::Compound(CompoundField::from_field(field, compound, compact)?) + } else { + ModelExFieldKind::Scalar(ScalarField::from_field(field)?) + }; + + Ok(Self { ident, kind }) + } +} + +enum RelationVariants { + Inferred, + Named(LitStr), + SelfRef { + forward_variant: LitStr, + reverse_variant: Option, + }, +} + +impl RelationVariants { + fn from_attr( + relation_enum: Option, + relation_reverse: Option, + self_ref: Option<()>, + field: &Field, + ) -> syn::Result { + if self_ref.is_some() { + Ok(Self::SelfRef { + forward_variant: relation_enum.ok_or_else(|| { + syn::Error::new_spanned(field, "self_ref must specify `relation_enum`") + })?, + reverse_variant: relation_reverse, }) - })?; + } else if let Some(relation_enum) = relation_enum { + Ok(Self::Named(relation_enum)) + } else { + Ok(Self::Inferred) + } + } + + fn explicit_name(&self) -> Option<&LitStr> { + match self { + Self::Named(name) + | Self::SelfRef { + forward_variant: name, + .. + } => Some(name), + Self::Inferred => None, + } + } + + fn forward_ident(&self, entity: &TypePath) -> Ident { + if let Some(name) = self.explicit_name() { + Ident::new(&name.value().to_upper_camel_case(), name.span()) + } else { + Ident::new( + &infer_relation_name_from_entity(entity).to_upper_camel_case(), + Span::call_site(), + ) + } + } + + fn reverse_ident(&self, forward: &Ident) -> Option { + if let Self::SelfRef { + reverse_variant, .. + } = self + { + Some(if let Some(reverse_variant) = reverse_variant { + Ident::new(&reverse_variant.value(), reverse_variant.span()) + } else { + Ident::new(&format!("{forward}Reverse"), forward.span()) + }) + } else { + None + } + } + + fn is_self_ref(&self) -> bool { + matches!(self, Self::SelfRef { .. }) + } +} + +#[derive(Default)] +struct ModelExRelationOutput { + relation_enum_variants: Punctuated, + related_entity_enum_variants: Punctuated, + impl_related_trait: TokenStream, +} + +impl ModelExRelationOutput { + fn from_schema(schema: &ModelExSchema<'_>) -> Self { + let fields = schema + .fields + .iter() + .filter_map(|field| { + let ModelExFieldKind::Compound(compound) = &field.kind else { + return None; + }; + if matches!( + &compound.kind, + CompoundFieldKind::BelongsTo(Some(_)) + | CompoundFieldKind::HasOne(Some(_)) + | CompoundFieldKind::HasMany(Some(_)) + ) { + Some(compound) + } else { + None + } + }) + .collect::>(); + let mut entity_count = HashMap::<&TypePath, usize>::new(); + for field in &fields { + *entity_count.entry(&field.entity).or_default() += 1; + } + + let mut output = Self::default(); + for field in fields { + let is_unique_entity = entity_count + .get(&field.entity) + .is_some_and(|count| *count == 1); + match &field.kind { + CompoundFieldKind::BelongsTo(Some(attr)) => { + expand_belongs_to_into(&mut output, &field.entity, attr, is_unique_entity); + } + CompoundFieldKind::HasOne(Some(attr)) => { + expand_has_one_into(&mut output, &field.entity, attr, is_unique_entity); + } + CompoundFieldKind::HasMany(Some(attr)) => { + expand_has_many_into(&mut output, &field.entity, attr, is_unique_entity); + } + CompoundFieldKind::BelongsTo(None) + | CompoundFieldKind::HasOne(None) + | CompoundFieldKind::HasMany(None) => {} + } + } + output + } +} + +enum CompoundFieldKind { + BelongsTo(Option), + HasOne(Option), + HasMany(Option), +} + +struct CompoundField { + entity: TypePath, + kind: CompoundFieldKind, +} + +impl CompoundField { + fn from_field(field: &Field, compound: CompoundType, compact: bool) -> syn::Result { + let attrs = compound_attr::SeaOrm::try_from_attributes(&field.attrs)?; + if compact + && attrs.as_ref().is_some_and(|attrs| { + attrs.has_one.is_some() || attrs.has_many.is_some() || attrs.belongs_to.is_some() + }) + { + return Err(syn::Error::new_spanned( + field, + "You cannot use #[has_one / has_many / belongs_to] on #[sea_orm::compact_model], please use #[sea_orm::model] instead.", + )); + } + + let is_self_entity = is_self_entity(&compound.entity); + // A `belongs_to` relation may keep the legacy `HasOne` field type instead of + // opting into `BelongsTo`. Detect that so it is routed through the belongs_to + // codegen (emitting a `belongs_to` relation); only the wrapper type differs. + // `BelongsTo` remains the recommended type. + let declares_belongs_to = attrs.as_ref().is_some_and(|attrs| { + attrs.belongs_to.is_some() + || (attrs.self_ref.is_some() + && attrs.via.is_none() + && attrs.from.is_some() + && attrs.to.is_some()) + }); + let kind = match compound.kind { + CompoundKind::BelongsTo(_) => CompoundFieldKind::BelongsTo( + attrs + .map(|attrs| BelongsToAttr::from_attr(attrs, field)) + .transpose()?, + ), + CompoundKind::HasOne if declares_belongs_to => CompoundFieldKind::BelongsTo( + attrs + .map(|attrs| BelongsToAttr::from_attr(attrs, field)) + .transpose()?, + ), + CompoundKind::HasOne => CompoundFieldKind::HasOne( + attrs + .map(|attrs| HasOneAttr::from_attr(attrs, field)) + .transpose()?, + ), + CompoundKind::HasMany => CompoundFieldKind::HasMany( + attrs + .map(|attrs| HasManyAttr::from_attr(attrs, field, is_self_entity)) + .transpose()?, + ), + }; + Ok(Self { + entity: compound.entity, + kind, + }) + } +} + +fn entity_loader_field(field: &Ident, compound: &CompoundField) -> EntityLoaderField { + let self_entity = is_self_entity(&compound.entity); + let (relation_enum, kind) = match &compound.kind { + CompoundFieldKind::BelongsTo(attr) => ( + attr.as_ref() + .and_then(|attr| attr.relation_variants.explicit_name()) + .cloned(), + if self_entity { + EntityLoaderFieldKind::SelfHasOne + } else { + EntityLoaderFieldKind::HasOne + }, + ), + CompoundFieldKind::HasOne(attr) => ( + attr.as_ref() + .and_then(|attr| attr.relation_variants.explicit_name()) + .cloned(), + if self_entity { + EntityLoaderFieldKind::SelfHasOne + } else { + EntityLoaderFieldKind::HasOne + }, + ), + CompoundFieldKind::HasMany(attr) => { + let (relation_enum, via, reverse) = match attr { + None => (None, None, false), + Some(HasManyAttr::Standard { + relation_variants, + via, + reverse, + .. + }) => ( + relation_variants.explicit_name().cloned(), + via.clone(), + *reverse, + ), + Some(HasManyAttr::SelfVia { + relation_enum, + via, + direction, + }) => ( + relation_enum.clone(), + Some(via.clone()), + matches!(direction, SelfViaDirection::Reverse), + ), + }; + let kind = if self_entity { + if let Some(via) = via { + EntityLoaderFieldKind::SelfHasManyVia { via, reverse } + } else { + EntityLoaderFieldKind::SelfHasMany + } + } else { + EntityLoaderFieldKind::HasMany { via } + }; + (relation_enum, kind) + } + }; + EntityLoaderField { + field: field.clone(), + entity: compound.entity.clone(), + relation_enum, + kind, + } +} + +impl EntityLoaderSchema { + fn from_model_ex_schema(schema: &ModelExSchema<'_>) -> Self { + Self { + fields: schema + .fields + .iter() + .filter_map(|field| { + let ModelExFieldKind::Compound(compound) = &field.kind else { + return None; + }; + Some(entity_loader_field(field.ident, compound)) + }) + .collect(), + } + } +} - if let Data::Struct(item_struct) = &data { - if let Fields::Named(fields) = &item_struct.fields { - for field in &fields.named { - if let Some(ident) = &field.ident { - let field_type = &field.ty; - let field_type: String = quote! { #field_type } - .to_string() // e.g.: "Option < String >" - .split_whitespace() - .collect(); // Remove all whitespace - - if is_compound_field(&field_type) { - let compound_attrs = - compound_attr::SeaOrm::from_attributes(&field.attrs).ok(); - let is_reverse = compound_attrs - .as_ref() - .map(|r| r.reverse.is_some()) - .unwrap_or_default(); - let relation_enum = compound_attrs - .as_ref() - .and_then(|r| r.relation_enum.clone()); - if field_type.starts_with("HasOne<") { - entity_loader_schema.fields.push(EntityLoaderField { - is_one: true, - is_self: field_type == "HasOne", - is_reverse, - field: ident.clone(), - entity: extract_compound_entity(&field_type).to_owned(), - relation_enum, - via: None, - }); - } else if field_type.starts_with("HasMany<") { - entity_loader_schema.fields.push(EntityLoaderField { - is_one: false, - is_self: field_type == "HasMany", - is_reverse, - field: ident.clone(), - entity: extract_compound_entity(&field_type).to_owned(), - relation_enum, - via: compound_attrs.as_ref().and_then(|r| r.via.clone()), - }); - } - if let Some(attrs) = compound_attrs { - if compact - && (attrs.has_one.is_some() - || attrs.has_many.is_some() - || attrs.belongs_to.is_some()) - { - return Err(syn::Error::new_spanned( - ident, - "You cannot use #[has_one / has_many / belongs_to] on #[sea_orm::compact_model], please use #[sea_orm::model] instead.", - )); - } else if attrs.belongs_to.is_some() - && !field_type.starts_with("HasOne<") - { - return Err(syn::Error::new_spanned( - ident, - "belongs_to must be paired with HasOne", - )); - } else if attrs.has_one.is_some() && !field_type.starts_with("HasOne<") - { - return Err(syn::Error::new_spanned( - ident, - "has_one must be paired with HasOne", - )); - } else if attrs.has_many.is_some() - && !field_type.starts_with("HasMany<") - { - return Err(syn::Error::new_spanned( - ident, - "has_many must be paired with HasMany", - )); - } - impl_related.push((attrs, field_type)); - } - compound_fields.push(format_field_ident(field)); +fn expand_related_entity_variants_into( + entity: &TypePath, + relation_variants: &RelationVariants, + output: &mut ModelExRelationOutput, +) { + let relation_enum = relation_variants.forward_ident(entity); + let related_entity_lit = entity + .path + .segments + .iter() + .map(|segment| segment.ident.to_string()) + .collect::>() + .join("::"); + let extra = if relation_variants.explicit_name().is_some() { + let relation_def = format!("Relation::{relation_enum}.def()"); + quote!(, def = #relation_def) + } else { + quote!() + }; + output.related_entity_enum_variants.push(quote! { + #[doc = " Generated by sea-orm-macros"] + #[sea_orm(entity = #related_entity_lit #extra)] + #relation_enum + }); + + if let Some(relation_enum_ref) = relation_variants.reverse_ident(&relation_enum) { + let relation_def = format!("Relation::{relation_enum}.def().rev()"); + output.related_entity_enum_variants.push(quote! { + #[doc = " Generated by sea-orm-macros"] + #[sea_orm(entity = #related_entity_lit def = #relation_def)] + #relation_enum_ref + }); + } +} + +fn expand_related_trait(entity: &TypePath, relation_variants: &RelationVariants) -> TokenStream { + let relation_enum = relation_variants.forward_ident(entity); + quote! { + #[doc = " Generated by sea-orm-macros"] + impl Related<#entity> for Entity { + fn to() -> RelationDef { + Relation::#relation_enum.def() + } + } + } +} + +fn expand_related_via_trait( + entity: &TypePath, + relation_variants: &RelationVariants, + via: &LitStr, +) -> TokenStream { + let relation_enum = relation_variants.forward_ident(entity); + let value = via.value(); + let mut junction = value.as_str(); + let mut via_related = ""; + if let Some((prefix, suffix)) = value.split_once("::") { + junction = prefix; + via_related = suffix; + } + let junction = Ident::new(junction, via.span()); + let relation_def = quote!(super::#junction::Relation::#relation_enum.def()); + let via_relation_def = if via_related.is_empty() { + quote!(>::to().rev()) + } else { + let via_related = Ident::new(via_related, via.span()); + quote!(super::#junction::Relation::#via_related.def().rev()) + }; + quote! { + #[doc = " Generated by sea-orm-macros"] + impl Related<#entity> for Entity { + fn to() -> RelationDef { + #relation_def + } + fn via() -> Option { + Some(#via_relation_def) + } + } + } +} + +fn expand_related_into( + output: &mut ModelExRelationOutput, + entity: &TypePath, + relation_variants: &RelationVariants, + via: Option<&LitStr>, + impl_related: bool, +) { + expand_related_entity_variants_into(entity, relation_variants, output); + if impl_related { + output.impl_related_trait.extend(if let Some(via) = via { + expand_related_via_trait(entity, relation_variants, via) + } else { + expand_related_trait(entity, relation_variants) + }); + } +} + +struct RelationColumns(Vec); + +impl RelationColumns { + fn from_lit(lit: LitStr) -> syn::Result { + let paths = if lit.value().starts_with('(') { + lit.parse_with(|input: syn::parse::ParseStream<'_>| { + let content; + syn::parenthesized!(content in input); + content.parse_terminated(syn::Path::parse_mod_style, Comma) + })? + } else { + let mut paths = Punctuated::new(); + paths.push(lit.parse()?); + paths + }; + if paths.is_empty() { + return Err(syn::Error::new(lit.span(), "expected at least one column")); + } + + paths + .into_iter() + .map(|path| { + let Some(segment) = path.segments.last() else { + return Err(syn::Error::new_spanned(path, "expected column path")); + }; + Ok(Ident::new( + &segment.ident.to_string().to_upper_camel_case(), + segment.ident.span(), + )) + }) + .collect::>>() + .map(Self) + } +} + +struct BelongsToRelationAttr { + declared: bool, + via: Option, + from: RelationColumns, + to: RelationColumns, + on_update: Option, + on_delete: Option, + skip_fk: bool, +} + +struct BelongsToAttr { + relation_variants: RelationVariants, + relation: Option, +} + +impl BelongsToAttr { + fn from_attr(attrs: compound_attr::SeaOrm, field: &Field) -> syn::Result { + let compound_attr::SeaOrm { + has_one, + has_many, + belongs_to, + self_ref, + skip_fk, + via, + from, + to, + relation_enum, + relation_reverse, + on_update, + on_delete, + .. + } = attrs; + + let is_self_ref = self_ref.is_some(); + let mut error = None; + + if has_one.is_some() { + combine_error( + &mut error, + syn::Error::new_spanned(&field.ty, "has_one must be paired with HasOne"), + ); + } + if has_many.is_some() { + combine_error( + &mut error, + syn::Error::new_spanned(&field.ty, "has_many must be paired with HasMany"), + ); + } + if is_self_ref && via.is_some() { + combine_error( + &mut error, + syn::Error::new_spanned( + &field.ty, + "self_ref + via field type must be `HasMany`", + ), + ); + } + + let relation_variants = if is_self_ref && via.is_some() { + None + } else { + match RelationVariants::from_attr(relation_enum, relation_reverse, self_ref, field) { + Ok(relation_variants) => Some(relation_variants), + Err(err) => { + combine_error(&mut error, err); + None + } + } + }; + + let from = if belongs_to.is_some() && from.is_none() { + combine_error( + &mut error, + syn::Error::new_spanned( + field, + "#[sea_orm(belongs_to)] must include `from = \"...\"` to name the local foreign key column", + ), + ); + None + } else { + from + }; + let to = if belongs_to.is_some() && to.is_none() { + combine_error( + &mut error, + syn::Error::new_spanned( + field, + "#[sea_orm(belongs_to)] must include `to = \"...\"` to name the related primary key column", + ), + ); + None + } else { + to + }; + + let from = match from.map(RelationColumns::from_lit).transpose() { + Ok(from) => from, + Err(err) => { + combine_error(&mut error, err); + None + } + }; + let to = match to.map(RelationColumns::from_lit).transpose() { + Ok(to) => to, + Err(err) => { + combine_error(&mut error, err); + None + } + }; + + if let Some(err) = error { + return Err(err); + } + + let relation_variants = relation_variants.expect("validated"); + let relation = if belongs_to.is_some() { + Some(BelongsToRelationAttr { + declared: true, + via, + from: from.expect("validated"), + to: to.expect("validated"), + on_update, + on_delete, + skip_fk: skip_fk.is_some(), + }) + } else if relation_variants.is_self_ref() + && let (Some(from), Some(to)) = (from, to) + { + Some(BelongsToRelationAttr { + declared: false, + via: None, + from, + to, + on_update, + on_delete, + skip_fk: skip_fk.is_some(), + }) + } else { + None + }; + Ok(Self { + relation_variants, + relation, + }) + } +} + +fn expand_belongs_to_into( + output: &mut ModelExRelationOutput, + entity: &TypePath, + attr: &BelongsToAttr, + unique_entity: bool, +) { + let relation_variants = &attr.relation_variants; + if let Some(relation) = &attr.relation { + let relation_enum = relation_variants.forward_ident(entity); + let related_entity_lit = entity + .path + .segments + .iter() + .map(|segment| segment.ident.to_string()) + .collect::>() + .join("::"); + let belongs_to = Ident::new("belongs_to", Span::call_site()); + let format_columns = |columns: &RelationColumns, prefix: &str| { + let columns = columns + .0 + .iter() + .map(|column| { + if prefix.is_empty() { + format!("Column::{column}") } else { - // scalar field - for attr in field.attrs.iter() { - // still have to parse column attributes to extract unique keys - if attr.path().is_ident("sea_orm") { - attr.parse_nested_meta(|meta| { - if meta.path.is_ident("unique") { - unique_keys.insert( - ident.clone(), - vec![(ident.clone(), field.ty.clone())], - ); - } else if meta.path.is_ident("unique_key") { - let lit = meta.value()?.parse()?; - if let Lit::Str(litstr) = lit { - unique_keys - .entry(litstr.parse()?) - .or_default() - .push((ident.clone(), field.ty.clone())); - } else { - return Err( - meta.error(format!("Invalid unique_key {lit:?}")) - ); - } - } else { - // Reads the value expression to advance the parse stream. - let _: Option = - meta.value().and_then(|v| v.parse()).ok(); - } - - Ok(()) - })?; - } - } - model_fields.push(format_field_ident(field)); + format!("{prefix}::Column::{column}") } + }) + .collect::>(); + if columns.len() > 1 { + format!("({})", columns.join(", ")) + } else { + columns[0].clone() + } + }; + let from = format_columns(&relation.from, ""); + let (related_entity, to) = if relation_variants.is_self_ref() { + ("Entity", format_columns(&relation.to, "")) + } else { + ( + related_entity_lit.as_str(), + format_columns( + &relation.to, + related_entity_lit.trim_end_matches("::Entity"), + ), + ) + }; + let mut extra: Punctuated<_, Comma> = Punctuated::new(); + if let Some(on_update) = &relation.on_update { + let tag = Ident::new("on_update", on_update.span()); + extra.push(quote!(#tag = #on_update)); + } + if let Some(on_delete) = &relation.on_delete { + let tag = Ident::new("on_delete", on_delete.span()); + extra.push(quote!(#tag = #on_delete)); + } + if relation.skip_fk { + extra.push(quote!(skip_fk)); + } + output.relation_enum_variants.push(quote! { + #[doc = " Generated by sea-orm-macros"] + #[sea_orm(#belongs_to = #related_entity, from = #from, to = #to, #extra)] + #relation_enum + }); + } + + let (declared, via) = attr + .relation + .as_ref() + .map(|relation| (relation.declared, relation.via.as_ref())) + .unwrap_or_default(); + expand_related_into( + output, + entity, + relation_variants, + via, + unique_entity && declared, + ); +} + +struct HasOneAttr { + relation_variants: RelationVariants, + /// Whether `#[sea_orm(has_one)]` is present. + has_one: bool, + via: Option, + via_rel: Option, +} + +impl HasOneAttr { + fn from_attr(attrs: compound_attr::SeaOrm, field: &Field) -> syn::Result { + let compound_attr::SeaOrm { + has_one, + has_many, + belongs_to, + self_ref, + via, + via_rel, + from, + to, + relation_enum, + relation_reverse, + .. + } = attrs; + + let is_self_ref = self_ref.is_some(); + let mut error = None; + + if belongs_to.is_some() { + combine_error( + &mut error, + syn::Error::new_spanned(&field.ty, "belongs_to must be paired with BelongsTo"), + ); + } + if has_many.is_some() { + combine_error( + &mut error, + syn::Error::new_spanned(&field.ty, "has_many must be paired with HasMany"), + ); + } + if is_self_ref && via.is_some() { + combine_error( + &mut error, + syn::Error::new_spanned( + &field.ty, + "self_ref + via field type must be `HasMany`", + ), + ); + } + + let relation_variants = if is_self_ref && via.is_some() { + None + } else { + match RelationVariants::from_attr(relation_enum, relation_reverse, self_ref, field) { + Ok(relation_variants) => Some(relation_variants), + Err(err) => { + combine_error(&mut error, err); + None } } + }; + if is_self_ref && from.is_some() && to.is_some() { + combine_error( + &mut error, + syn::Error::new_spanned( + &field.ty, + "self_ref belongs_to must be paired with BelongsTo", + ), + ); + } + + if let Some(err) = error { + return Err(err); } + + let relation_variants = relation_variants.expect("validated relation variants"); + Ok(Self { + relation_variants, + has_one: has_one.is_some(), + via, + via_rel, + }) } +} + +fn expand_has_one_into( + output: &mut ModelExRelationOutput, + entity: &TypePath, + attr: &HasOneAttr, + unique_entity: bool, +) { + let relation_variants = &attr.relation_variants; + if attr.has_one { + let relation_enum = relation_variants.forward_ident(entity); + let related_entity_lit = entity + .path + .segments + .iter() + .map(|segment| segment.ident.to_string()) + .collect::>() + .join("::"); + let has_one = Ident::new("has_one", Span::call_site()); + let mut extra: Punctuated<_, Comma> = Punctuated::new(); + if let Some(via_rel) = &attr.via_rel { + let tag = Ident::new("via_rel", via_rel.span()); + let via_rel = format!("Relation::{}", via_rel.value()); + extra.push(quote!(#tag = #via_rel)); + } + output.relation_enum_variants.push(quote! { + #[doc = " Generated by sea-orm-macros"] + #[sea_orm(#has_one = #related_entity_lit, #extra)] + #relation_enum + }); + } + + expand_related_into( + output, + entity, + relation_variants, + attr.via.as_ref(), + unique_entity && attr.has_one, + ); +} + +enum HasManyAttr { + Standard { + relation_variants: RelationVariants, + has_many: bool, + via: Option, + via_rel: Option, + reverse: bool, + }, + SelfVia { + relation_enum: Option, + via: LitStr, + direction: SelfViaDirection, + }, +} + +enum SelfViaDirection { + Forward { from: LitStr, to: LitStr }, + Reverse, + Manual, +} + +impl HasManyAttr { + fn from_attr( + attrs: compound_attr::SeaOrm, + field: &Field, + is_self_entity: bool, + ) -> syn::Result { + let compound_attr::SeaOrm { + has_one, + has_many, + belongs_to, + self_ref, + via, + via_rel, + from, + to, + relation_enum, + relation_reverse, + reverse, + .. + } = attrs; + + let is_self_ref = self_ref.is_some(); + let mut error = None; + + if belongs_to.is_some() { + combine_error( + &mut error, + syn::Error::new_spanned(&field.ty, "belongs_to must be paired with BelongsTo"), + ); + } + if has_one.is_some() { + combine_error( + &mut error, + syn::Error::new_spanned(&field.ty, "has_one must be paired with HasOne"), + ); + } + if is_self_ref && let Some(via) = via { + if !is_self_entity { + combine_error( + &mut error, + syn::Error::new_spanned( + &field.ty, + "self_ref + via field type must be `HasMany`", + ), + ); + } + + if let Some(err) = error { + return Err(err); + } + + let direction = if reverse.is_some() { + SelfViaDirection::Reverse + } else if let (Some(from), Some(to)) = (from, to) { + SelfViaDirection::Forward { from, to } + } else { + SelfViaDirection::Manual + }; + return Ok(Self::SelfVia { + relation_enum, + via, + direction, + }); + } + + let relation_variants = + match RelationVariants::from_attr(relation_enum, relation_reverse, self_ref, field) { + Ok(relation_variants) => Some(relation_variants), + Err(err) => { + combine_error(&mut error, err); + None + } + }; + if is_self_ref && from.is_some() && to.is_some() { + combine_error( + &mut error, + syn::Error::new_spanned( + &field.ty, + "self_ref belongs_to must be paired with BelongsTo", + ), + ); + } + + if let Some(err) = error { + return Err(err); + } + let relation_variants = relation_variants.expect("validated relation variants"); + Ok(Self::Standard { + relation_variants, + has_many: has_many.is_some(), + via, + via_rel, + reverse: has_many.is_none() && reverse.is_some(), + }) + } +} + +fn expand_has_many_into( + output: &mut ModelExRelationOutput, + entity: &TypePath, + attr: &HasManyAttr, + unique_entity: bool, +) { + let (relation_variants, has_many, via, via_rel) = match attr { + HasManyAttr::Standard { + relation_variants, + has_many, + via, + via_rel, + .. + } => (relation_variants, *has_many, via, via_rel), + HasManyAttr::SelfVia { via, direction, .. } => { + output + .impl_related_trait + .extend(expand_impl_related_self_via(via, direction)); + return; + } + }; + let relation_enum = relation_variants.forward_ident(entity); + if let RelationVariants::SelfRef { + reverse_variant: Some(relation_reverse), + .. + } = relation_variants + { + let has_many = Ident::new("has_many", Span::call_site()); + let via_rel = format!("Relation::{}", relation_reverse.value()); + output.relation_enum_variants.push(quote! { + #[doc = " Generated by sea-orm-macros"] + #[sea_orm(#has_many = "Entity", via_rel = #via_rel)] + #relation_enum + }); + } else if has_many && via.is_none() { + let related_entity_lit = entity + .path + .segments + .iter() + .map(|segment| segment.ident.to_string()) + .collect::>() + .join("::"); + let has_many = Ident::new("has_many", Span::call_site()); + let mut extra: Punctuated<_, Comma> = Punctuated::new(); + if let Some(via_rel) = via_rel { + let tag = Ident::new("via_rel", via_rel.span()); + let via_rel = format!("Relation::{}", via_rel.value()); + extra.push(quote!(#tag = #via_rel)); + } + output.relation_enum_variants.push(quote! { + #[doc = " Generated by sea-orm-macros"] + #[sea_orm(#has_many = #related_entity_lit, #extra)] + #relation_enum + }); + } + + if relation_variants.is_self_ref() { + return; + } + expand_related_into( + output, + entity, + relation_variants, + via.as_ref(), + unique_entity && has_many, + ); +} + +struct ModelExSchema<'a> { + compact: bool, + fields: Vec>, +} + +impl<'a> ModelExSchema<'a> { + fn from_data(data: &'a Data, attrs: &[Attribute], ident: &Ident) -> syn::Result { + let mut compact = false; + attrs + .iter() + .filter(|attr| attr.path().is_ident("sea_orm")) + .try_for_each(|attr| { + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("compact_model") { + compact = true; + } else { + consume_meta(meta); + } + Ok(()) + }) + })?; + + let fields = if let Data::Struct(r#struct) = data + && let Fields::Named(fields) = &r#struct.fields + { + fields + .named + .iter() + .map(|field| ModelExField::from_field(field, compact)) + .collect::>>()? + } else { + return Err(syn::Error::new_spanned( + ident, + "You can only derive DeriveModelEx on structs", + )); + }; + + Ok(Self { compact, fields }) + } +} + +pub fn expand_derive_model_ex( + vis: &Visibility, + ident: Ident, + data: Data, + attrs: Vec, +) -> syn::Result { + let schema = ModelExSchema::from_data(&data, &attrs, &ident)?; + let compact = schema.compact; + let model_fields = schema + .fields + .iter() + .filter_map(|field| { + matches!(&field.kind, ModelExFieldKind::Scalar(_)).then_some(field.ident) + }) + .collect::>(); + let compound_fields = schema + .fields + .iter() + .filter_map(|field| { + matches!(&field.kind, ModelExFieldKind::Compound(_)).then_some(field.ident) + }) + .collect::>(); let impl_model_trait = DeriveModel::new(&ident, &data, &attrs)?.impl_model_trait(); @@ -328,50 +1280,11 @@ pub fn expand_derive_model_ex( } }; - let mut relation_enum_variants: Punctuated<_, Comma> = Punctuated::new(); - let mut related_entity_enum_variants: Punctuated<_, Comma> = Punctuated::new(); - - let impl_related_trait = { - let mut ts = TokenStream::new(); - let mut seen_entity = HashMap::new(); - for (_, field_type) in impl_related.iter() { - let entity_path = extract_compound_entity(field_type); - *seen_entity.entry(entity_path).or_insert(0) += 1; - } - - for (attrs, field_type) in impl_related.iter() { - if attrs.self_ref.is_some() && attrs.via.is_some() { - ts.extend(expand_impl_related_self_via(attrs, field_type)?); - } else { - if attrs.self_ref.is_some() && attrs.relation_enum.is_none() { - return Err(syn::Error::new_spanned( - ident, - "Please specify `relation_enum` for `self_ref`", - )); - } - if let Some(var) = relation_enum_variant(attrs, field_type) { - relation_enum_variants.push(var); - } - if attrs.self_ref.is_some() && field_type.starts_with("HasMany<") { - // related entity is already provided by the HasOne item - // so self_ref HasMany has to be skipped - continue; - } - let (first, second) = related_entity_enum_variant(attrs, field_type); - related_entity_enum_variants.push(first); - if let Some(second) = second { - related_entity_enum_variants.push(second); - } - let entity_path = extract_compound_entity(field_type); - if *seen_entity.get(entity_path).unwrap() == 1 { - // prevent impl trait for same entity twice - ts.extend(expand_impl_related_trait(attrs, field_type)?); - } - } - } - - ts - }; + let ModelExRelationOutput { + relation_enum_variants, + related_entity_enum_variants, + impl_related_trait, + } = ModelExRelationOutput::from_schema(&schema); let relation_enum = if !compact { quote! { @@ -401,9 +1314,10 @@ pub fn expand_derive_model_ex( let (typed_column, typed_column_const) = expand_typed_column(vis, &data)?; - let (entity_find_by_key, loader_filter_by_key) = expand_find_by_unique_key(unique_keys); + let (entity_find_by_key, loader_filter_by_key) = expand_find_by_unique_key(&schema); - let entity_loader = expand_entity_loader(vis, entity_loader_schema); + let entity_loader = + expand_entity_loader(vis, EntityLoaderSchema::from_model_ex_schema(&schema)); Ok(quote! { #typed_column @@ -435,312 +1349,71 @@ pub fn expand_derive_model_ex( }) } -fn relation_enum_variant(attr: &compound_attr::SeaOrm, ty: &str) -> Option { - let (related_entity, relation_enum) = get_related(attr, ty); - if attr.belongs_to.is_some() { - let belongs_to = Ident::new("belongs_to", Span::call_site()); - - let from = format_tuple( - "", - "Column", - &attr - .from - .as_ref() - .expect("Must specify `from` and `to` on belongs_to relation") - .value(), - ); - let to = format_tuple( - related_entity.trim_end_matches("::Entity"), - "Column", - &attr - .to - .as_ref() - .expect("Must specify `from` and `to` on belongs_to relation") - .value(), - ); - let mut extra: Punctuated<_, Comma> = Punctuated::new(); - if let Some(on_update) = &attr.on_update { - let tag = Ident::new("on_update", on_update.span()); - extra.push(quote!(#tag = #on_update)) - } - if let Some(on_delete) = &attr.on_delete { - let tag = Ident::new("on_delete", on_delete.span()); - extra.push(quote!(#tag = #on_delete)) - } - if let Some(()) = &attr.skip_fk { - extra.push(quote!(skip_fk)) - } - - Some(quote! { - #[doc = " Generated by sea-orm-macros"] - #[sea_orm(#belongs_to = #related_entity, from = #from, to = #to, #extra)] - #relation_enum - }) - } else if attr.self_ref.is_some() - && attr.via.is_none() - && attr.from.is_some() - && attr.to.is_some() - { - let belongs_to = Ident::new("belongs_to", Span::call_site()); - - let from = format_tuple("", "Column", &attr.from.as_ref().unwrap().value()); - let to = format_tuple("", "Column", &attr.to.as_ref().unwrap().value()); - let mut extra: Punctuated<_, Comma> = Punctuated::new(); - if let Some(on_update) = &attr.on_update { - let tag = Ident::new("on_update", on_update.span()); - extra.push(quote!(#tag = #on_update)) - } - if let Some(on_delete) = &attr.on_delete { - let tag = Ident::new("on_delete", on_delete.span()); - extra.push(quote!(#tag = #on_delete)) - } - if let Some(()) = &attr.skip_fk { - extra.push(quote!(skip_fk)) - } - - Some(quote! { - #[doc = " Generated by sea-orm-macros"] - #[sea_orm(#belongs_to = "Entity", from = #from, to = #to, #extra)] - #relation_enum - }) - } else if attr.self_ref.is_some() - && attr.via.is_none() - && attr.relation_reverse.is_some() - && ty.starts_with("HasMany<") - { - let has_many = Ident::new("has_many", Span::call_site()); - - #[allow(clippy::unnecessary_unwrap)] - let via_rel = format!( - "Relation::{}", - attr.relation_reverse.as_ref().unwrap().value() - ); - - Some(quote! { - #[doc = " Generated by sea-orm-macros"] - #[sea_orm(#has_many = "Entity", via_rel = #via_rel)] - #relation_enum - }) - } else if attr.has_many.is_some() && attr.via.is_none() { - // skip junction relation - - let has_many = Ident::new("has_many", Span::call_site()); - let mut extra: Punctuated<_, Comma> = Punctuated::new(); - if let Some(via_rel) = &attr.via_rel { - let tag = Ident::new("via_rel", via_rel.span()); - let via_rel = format!("Relation::{}", via_rel.value()); - extra.push(quote!(#tag = #via_rel)) - } - - Some(quote! { - #[doc = " Generated by sea-orm-macros"] - #[sea_orm(#has_many = #related_entity, #extra)] - #relation_enum - }) - } else if attr.has_one.is_some() { - let has_one = Ident::new("has_one", Span::call_site()); - let mut extra: Punctuated<_, Comma> = Punctuated::new(); - if let Some(via_rel) = &attr.via_rel { - let tag = Ident::new("via_rel", via_rel.span()); - let via_rel = format!("Relation::{}", via_rel.value()); - extra.push(quote!(#tag = #via_rel)) - } - - Some(quote! { - #[doc = " Generated by sea-orm-macros"] - #[sea_orm(#has_one = #related_entity, #extra)] - #relation_enum - }) - } else { - None - } -} - -fn related_entity_enum_variant( - attr: &compound_attr::SeaOrm, - ty: &str, -) -> (TokenStream, Option) { - let (related_entity, relation_enum) = get_related(attr, ty); - - let extra = if attr.relation_enum.is_some() { - let relation_def = format!("Relation::{relation_enum}.def()"); - quote!(, def = #relation_def) - } else { - quote!() - }; - - let first = quote! { - #[doc = " Generated by sea-orm-macros"] - #[sea_orm(entity = #related_entity #extra)] - #relation_enum - }; - let second = if attr.self_ref.is_some() { - let relation_def = format!("Relation::{relation_enum}.def().rev()"); - let relation_enum_ref = if let Some(relation_reverse) = &attr.relation_reverse { - Ident::new(&relation_reverse.value(), relation_reverse.span()) - } else { - Ident::new(&format!("{relation_enum}Reverse"), relation_enum.span()) - }; - Some(quote! { - #[doc = " Generated by sea-orm-macros"] - #[sea_orm(entity = #related_entity def = #relation_def)] - #relation_enum_ref - }) - } else { - None - }; - - (first, second) -} - -fn expand_impl_related_trait(attr: &compound_attr::SeaOrm, ty: &str) -> syn::Result { - if attr.has_one.is_some() || attr.has_many.is_some() || attr.belongs_to.is_some() { - let (related_entity, relation_enum) = get_related(attr, ty); - let related_entity: TokenStream = related_entity.parse().unwrap(); - - if let Some(via_lit) = &attr.via { - let via = via_lit.value(); - let mut junction = via.as_str(); - let mut via_related = ""; - if let Some((prefix, suffix)) = via.split_once("::") { - junction = prefix; - via_related = suffix; - } - let junction = Ident::new(junction, via_lit.span()); - let relation_def = quote!(super::#junction::Relation::#relation_enum.def()); - let via_relation_def: TokenStream = if !via_related.is_empty() { - let via_related = Ident::new(via_related, via_lit.span()); - quote!(super::#junction::Relation::#via_related.def().rev()) - } else { - quote!(>::to().rev()) - }; +fn expand_impl_related_self_via(via: &LitStr, direction: &SelfViaDirection) -> TokenStream { + match direction { + SelfViaDirection::Forward { from, to } => { + let junction = Ident::new(&via.value(), via.span()); + let from = Ident::new(&from.value(), from.span()); + let to = Ident::new(&to.value(), to.span()); - Ok(quote! { + quote! { #[doc = " Generated by sea-orm-macros"] - impl Related<#related_entity> for Entity { + impl RelatedSelfVia for Entity { fn to() -> RelationDef { - #relation_def + super::#junction::Relation::#to.def() } - fn via() -> Option { - Some(#via_relation_def) + fn via() -> RelationDef { + super::#junction::Relation::#from.def().rev() } } - }) + } - // #[sea_orm(relation, via = "cakes_bakers::Cake")] - // impl Related for Entity { + // #[sea_orm(self_ref, via = "user_follower", from = "User", to = "Follower")] + // impl RelatedSelfVia for Entity { // fn to() -> RelationDef { - // super::cakes_bakers::Relation::Baker.def() - // } - // fn via() -> Option { - // Some(super::cakes_bakers::Relation::Cake.def().rev()) + // super::user_follower::Relation::Follower.def() // } - // } - } else { - let relation_def = quote!(Relation::#relation_enum.def()); - Ok(quote! { - #[doc = " Generated by sea-orm-macros"] - impl Related<#related_entity> for Entity { - fn to() -> RelationDef { - #relation_def - } - } - }) - - // #[sea_orm(relation)] - // impl Related for Entity { - // fn to() -> RelationDef { - // Relation::Bakery.def() + // fn via() -> RelationDef { + // super::user_follower::Relation::User.def().rev() // } // } } - } else { - Ok(quote!()) + SelfViaDirection::Reverse | SelfViaDirection::Manual => quote!(), } } -fn expand_impl_related_self_via( - attr: &compound_attr::SeaOrm, - ty: &str, -) -> syn::Result { - let Some(via) = &attr.via else { - return Err(syn::Error::new( - Span::call_site(), - "Please specify the junction Entity `via` for `self_ref`.", - )); +pub(crate) fn infer_relation_name_from_entity(entity: &TypePath) -> String { + let mut segments = entity.path.segments.iter().rev(); + let Some(last) = segments.next() else { + return String::new(); }; - - if ty != "HasMany" { - return Err(syn::Error::new_spanned( - via, - "self_ref + via field type must be `HasMany`", - )); - } - - if attr.reverse.is_some() { - return Ok(quote!()); - } - - if let (Some(from), Some(to)) = (&attr.from, &attr.to) { - let junction = Ident::new(&via.value(), via.span()); - let from = Ident::new(&from.value(), from.span()); - let to = Ident::new(&to.value(), to.span()); - - Ok(quote! { - #[doc = " Generated by sea-orm-macros"] - impl RelatedSelfVia for Entity { - fn to() -> RelationDef { - super::#junction::Relation::#to.def() - } - fn via() -> RelationDef { - super::#junction::Relation::#from.def().rev() - } - } - }) - - // #[sea_orm(self_ref, via = "user_follower", from = "User", to = "Follower")] - // impl RelatedSelfVia for Entity { - // fn to() -> RelationDef { - // super::user_follower::Relation::Follower.def() - // } - - // fn via() -> RelationDef { - // super::user_follower::Relation::User.def().rev() - // } - // } - } else { - Ok(quote!()) - } -} - -fn get_related<'a>(attr: &compound_attr::SeaOrm, ty: &'a str) -> (&'a str, Ident) { - let related_entity = extract_compound_entity(ty); - let relation_enum = if let Some(relation_enum) = &attr.relation_enum { - Ident::new( - &relation_enum.value().to_upper_camel_case(), - relation_enum.span(), - ) - } else { - Ident::new( - &infer_relation_name_from_entity(related_entity).to_upper_camel_case(), - Span::call_site(), - ) - }; - (related_entity, relation_enum) + segments + .next() + .map(|segment| segment.ident.to_string()) + .unwrap_or_else(|| last.ident.to_string()) } -pub(crate) fn infer_relation_name_from_entity(s: &str) -> &str { - let s = s.trim_end_matches("::Entity"); - if let Some((_, suffix)) = s.rsplit_once("::") { - return suffix; +fn expand_find_by_unique_key(schema: &ModelExSchema<'_>) -> (TokenStream, TokenStream) { + let mut unique_keys = BTreeMap::>::new(); + for field in &schema.fields { + let ModelExFieldKind::Scalar(scalar) = &field.kind else { + continue; + }; + if scalar.unique { + unique_keys.insert( + field.ident.clone(), + vec![(field.ident.clone(), scalar.ty.clone())], + ); + } + for unique_key in &scalar.unique_keys { + unique_keys + .entry(unique_key.clone()) + .or_default() + .push((field.ident.clone(), scalar.ty.clone())); + } } - s -} -fn expand_find_by_unique_key( - unique_keys: BTreeMap>, -) -> (TokenStream, TokenStream) { let mut entity_find_by_key = TokenStream::new(); let mut loader_filter_by_key = TokenStream::new(); @@ -756,7 +1429,7 @@ fn expand_find_by_unique_key( .enumerate() .map(|(i, (col, _))| { let i = syn::Index::from(i); - let col = to_upper_camel_case(col); + let col = Ident::new(&col.to_string().to_upper_camel_case(), col.span()); quote!(Column::#col.eq(v.#i)) }) .collect::>(); @@ -782,7 +1455,8 @@ fn expand_find_by_unique_key( } }); } else { - let col = to_upper_camel_case(&columns[0].0); + let col = &columns[0].0; + let col = Ident::new(&col.to_string().to_upper_camel_case(), col.span()); let key_type = &columns[0].1; entity_find_by_key.extend(quote! { #[doc = " Generated by sea-orm-macros"] @@ -808,64 +1482,3 @@ fn expand_find_by_unique_key( (entity_find_by_key, loader_filter_by_key) } - -fn format_tuple(prefix: &str, middle: &str, suffix: &str) -> String { - use std::fmt::Write; - - let parts = if suffix.starts_with('(') && suffix.ends_with(')') { - suffix[1..suffix.len() - 1] - .split(',') - .map(|s| s.trim()) - .collect() - } else { - vec![suffix] - }; - - let mut output = String::new(); - if parts.len() > 1 { - output.write_char('(').unwrap(); - } - for (i, suffix) in parts.iter().enumerate() { - let mut part = String::new(); - part.write_str(prefix).unwrap(); - if !part.is_empty() { - part.write_str("::").unwrap(); - } - part.write_str(middle).unwrap(); - part.write_str("::").unwrap(); - part.write_str(&suffix.to_upper_camel_case()).unwrap(); - - if i > 0 { - output.write_str(", ").unwrap(); - } - output.write_str(&part).unwrap(); - } - if parts.len() > 1 { - output.write_char(')').unwrap(); - } - - output -} - -fn to_upper_camel_case(i: &Ident) -> Ident { - Ident::new(&i.to_string().to_upper_camel_case(), Span::call_site()) -} - -#[cfg(test)] -mod test { - use super::format_tuple; - - #[test] - fn test_format_tuple() { - assert_eq!(format_tuple("", "Column", "Id"), "Column::Id"); - assert_eq!(format_tuple("super", "Column", "Id"), "super::Column::Id"); - assert_eq!( - format_tuple("", "Column", "(A, B)"), - "(Column::A, Column::B)" - ); - assert_eq!( - format_tuple("super", "Column", "(A, B)"), - "(super::Column::A, super::Column::B)" - ); - } -} diff --git a/sea-orm-macros/src/derives/typed_column.rs b/sea-orm-macros/src/derives/typed_column.rs index 8048b85a95..34a1726ec7 100644 --- a/sea-orm-macros/src/derives/typed_column.rs +++ b/sea-orm-macros/src/derives/typed_column.rs @@ -1,10 +1,10 @@ -use super::util::{ - escape_rust_keyword, format_field_ident, is_compound_field, trim_starting_raw_identifier, -}; +use crate::derives::util::consume_meta; + +use super::util::{CompoundType, escape_rust_keyword, trim_starting_raw_identifier}; use heck::ToUpperCamelCase; use proc_macro2::{Ident, TokenStream}; use quote::quote; -use syn::{Data, Expr, Fields, Lit, Visibility, spanned::Spanned}; +use syn::{Data, Fields, Lit, Visibility, spanned::Spanned}; /// First is `struct TypedColumn`, second is the `const COLUMN` pub fn expand_typed_column( @@ -15,85 +15,74 @@ pub fn expand_typed_column( let mut column_types = Vec::new(); let mut column_values = Vec::new(); - if let Data::Struct(item_struct) = &data { - if let Fields::Named(fields) = &item_struct.fields { - for field in &fields.named { - if let Some(ident) = &field.ident { - let field_name = trim_starting_raw_identifier(ident); - let mut field_name = - Ident::new(&field_name.to_upper_camel_case(), ident.span()); - - let field_type = &field.ty; - let field_type: String = quote! { #field_type } - .to_string() // e.g.: "Option < String >" - .split_whitespace() - .collect(); // Remove all whitespace + if let Data::Struct(item_struct) = &data + && let Fields::Named(fields) = &item_struct.fields + { + for field in &fields.named { + let Some(ident) = &field.ident else { continue }; + let field_name = trim_starting_raw_identifier(ident); + let mut field_name = Ident::new(&field_name.to_upper_camel_case(), ident.span()); - let mut ignore = false; - let mut column_type = None; + let field_ty = &field.ty; - for attr in field.attrs.iter() { - if attr.path().is_ident("sea_orm") { - attr.parse_nested_meta(|meta| { - if meta.path.is_ident("column_type") { - let lit = meta.value()?.parse()?; - if let Lit::Str(litstr) = lit { - column_type = Some(litstr.value()); - } else { - return Err( - meta.error(format!("Invalid column_type {lit:?}")) - ); - } - } else if meta.path.is_ident("enum_name") { - let lit = meta.value()?.parse()?; - if let Lit::Str(litstr) = lit { - let ty: Ident = syn::parse_str(&litstr.value())?; - field_name = ty; - } else { - return Err( - meta.error(format!("Invalid enum_name {lit:?}")) - ); - } - } else if meta.path.is_ident("ignore") { - ignore = true; - } else { - // Reads the value expression to advance the parse stream. - let _: Option = meta.value().and_then(|v| v.parse()).ok(); - } + let mut ignore = false; + let mut column_type = None; - Ok(()) - })?; + for attr in &field.attrs { + if !attr.path().is_ident("sea_orm") { + continue; + } + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("column_type") { + let lit = meta.value()?.parse()?; + if let Lit::Str(litstr) = lit { + column_type = Some(litstr.value()); + } else { + return Err(meta.error(format!("Invalid column_type {lit:?}"))); } + } else if meta.path.is_ident("enum_name") { + let lit = meta.value()?.parse()?; + if let Lit::Str(litstr) = lit { + let ty: Ident = syn::parse_str(&litstr.value())?; + field_name = ty; + } else { + return Err(meta.error(format!("Invalid enum_name {lit:?}"))); + } + } else if meta.path.is_ident("ignore") { + ignore = true; + } else { + consume_meta(meta); } - if ignore { - continue; - } - - if is_compound_field(&field_type) { - continue; - } + Ok(()) + })?; + } - field_name = Ident::new(&escape_rust_keyword(field_name), ident.span()); + let compound = if let syn::Type::Path(type_path) = field_ty { + CompoundType::matches_type(type_path) + } else { + false + }; - column_fields.push(format_field_ident(field)); - let wrapper = super::value_type_match::column_type_wrapper( - &column_type, - &field_type, - field.span(), - ); - column_types.push(if let Some(wrapper) = &wrapper { - quote!(sea_orm::#wrapper) - } else { - quote!(Column) - }); - column_values.push(if let Some(wrapper) = &wrapper { - quote!(sea_orm::#wrapper(Column::#field_name)) - } else { - quote!(Column::#field_name) - }); - } + if ignore || compound { + continue; } + + field_name = Ident::new(&escape_rust_keyword(field_name), ident.span()); + + column_fields.push(ident.clone()); + let wrapper = + super::value_type_match::column_type_wrapper(&column_type, field_ty, field.span()); + column_types.push(if let Some(wrapper) = &wrapper { + quote!(sea_orm::#wrapper) + } else { + quote!(Column) + }); + column_values.push(if let Some(wrapper) = &wrapper { + quote!(sea_orm::#wrapper(Column::#field_name)) + } else { + quote!(Column::#field_name) + }); } } diff --git a/sea-orm-macros/src/derives/util.rs b/sea-orm-macros/src/derives/util.rs index e1c484cdf6..0c47875d42 100644 --- a/sea-orm-macros/src/derives/util.rs +++ b/sea-orm-macros/src/derives/util.rs @@ -1,71 +1,219 @@ use heck::ToUpperCamelCase; -use syn::{Field, Ident, Meta, MetaNameValue, punctuated::Punctuated, token::Comma}; - -/// Remove ignored fields and compound fields -pub(crate) fn field_not_ignored(field: &Field) -> bool { - let field_type = &field.ty; - let field_type: String = quote::quote! { #field_type } - .to_string() // e.g.: "Option < String >" - .split_whitespace() - .collect(); // Remove all whitespace +use proc_macro2::TokenStream; +use quote::quote; +use syn::{ + Field, GenericArgument, Ident, Meta, MetaNameValue, PathArguments, Type, TypePath, + meta::ParseNestedMeta, punctuated::Punctuated, token::Comma, +}; + +pub(crate) fn async_token() -> TokenStream { + if cfg!(feature = "async") { + quote!(async) + } else { + quote!() + } +} - if is_compound_field(&field_type) { - return false; +pub(crate) fn await_token() -> TokenStream { + if cfg!(feature = "async") { + quote!(.await) + } else { + quote!() } +} - field_not_ignored_compound(field) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CardinalityKind { + Required, + Optional, } -/// Remove ignored fields, compound fields okay -pub(crate) fn field_not_ignored_compound(field: &Field) -> bool { - for attr in field.attrs.iter() { - if let Some(ident) = attr.path().get_ident() { - if ident != "sea_orm" { - continue; - } - } else { - continue; - } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CompoundKind { + BelongsTo(CardinalityKind), + HasOne, + HasMany, +} + +#[derive(Clone)] +pub(crate) struct CompoundType { + pub(crate) kind: CompoundKind, + pub(crate) entity: TypePath, +} + +impl CompoundType { + /// Returns whether the field uses the compound relation wrapper syntax. + pub(crate) fn matches_type(type_path: &TypePath) -> bool { + last_path_segment(type_path).is_ok_and(|segment| { + matches!( + segment.ident.to_string().as_str(), + "BelongsTo" | "HasOne" | "HasMany" + ) + }) + } - if let Ok(list) = attr.parse_args_with(Punctuated::::parse_terminated) { - for meta in list.iter() { - if let Meta::Path(path) = meta { - if let Some(name) = path.get_ident() { - if name == "ignore" { - return false; - } + /// Parses `BelongsTo`, `BelongsTo>`, `HasOne`, and `HasMany`. + pub(crate) fn from_type(type_path: &TypePath) -> syn::Result> { + let segment = last_path_segment(type_path)?; + + match segment.ident.to_string().as_str() { + "BelongsTo" => { + let PathArguments::AngleBracketed(args) = &segment.arguments else { + return Err(syn::Error::new_spanned( + type_path, + "BelongsTo requires an Entity or Option generic argument", + )); + }; + let mut args = args.args.iter(); + let Some(GenericArgument::Type(ty)) = args.next() else { + return Err(syn::Error::new_spanned( + type_path, + "BelongsTo generic argument must be an Entity or Option", + )); + }; + if args.next().is_some() { + return Err(syn::Error::new_spanned( + type_path, + "BelongsTo requires an Entity or Option generic argument", + )); + } + let Type::Path(ty_path) = ty else { + return Err(syn::Error::new_spanned( + ty, + "BelongsTo generic argument must be an Entity or Option", + )); + }; + let target_segment = last_path_segment(ty_path)?; + match ( + target_segment.ident.to_string().as_str(), + &target_segment.arguments, + ) { + ("Entity", _) => Ok(Some(Self { + kind: CompoundKind::BelongsTo(CardinalityKind::Required), + entity: ty_path.clone(), + })), + ("Option", PathArguments::AngleBracketed(args)) => { + let Some(entity) = entity_generic_arg(&args.args) else { + return Err(syn::Error::new_spanned( + ty, + "BelongsTo optional target must be Option", + )); + }; + Ok(Some(Self { + kind: CompoundKind::BelongsTo(CardinalityKind::Optional), + entity, + })) } + _ => Err(syn::Error::new_spanned( + ty, + "BelongsTo generic argument must be an Entity or Option", + )), } } + "HasOne" => { + let PathArguments::AngleBracketed(args) = &segment.arguments else { + return Err(syn::Error::new_spanned( + type_path, + "HasOne requires an Entity generic argument", + )); + }; + let Some(entity) = entity_generic_arg(&args.args) else { + return Err(syn::Error::new_spanned( + type_path, + "HasOne requires an Entity generic argument", + )); + }; + Ok(Some(Self { + kind: CompoundKind::HasOne, + entity, + })) + } + "HasMany" => { + let PathArguments::AngleBracketed(args) = &segment.arguments else { + return Err(syn::Error::new_spanned( + type_path, + "HasMany requires an Entity generic argument", + )); + }; + let Some(entity) = entity_generic_arg(&args.args) else { + return Err(syn::Error::new_spanned( + type_path, + "HasMany requires an Entity generic argument", + )); + }; + Ok(Some(Self { + kind: CompoundKind::HasMany, + entity, + })) + } + _ => Ok(None), } } +} - true +fn last_path_segment(type_path: &TypePath) -> syn::Result<&syn::PathSegment> { + type_path + .path + .segments + .last() + .ok_or_else(|| syn::Error::new_spanned(type_path, "expected path type")) } -pub(crate) fn is_compound_field(field_type: &str) -> bool { - // for #[sea_orm::model] - ((field_type.starts_with("Option<") || field_type.starts_with("Vec<")) && field_type.ends_with("::Entity>")) - // for DeriveModelEx - || field_type.starts_with("HasOne<") || field_type.starts_with("HasMany<") +pub(crate) fn is_self_entity(entity: &TypePath) -> bool { + entity.path.segments.len() == 1 + && last_path_segment(entity).is_ok_and(|segment| segment.ident == "Entity") } -pub(crate) fn extract_compound_entity(ty: &str) -> &str { - if ty.starts_with("HasMany<") { - &ty["HasMany<".len()..(ty.len() - 1)] - } else if ty.starts_with("HasOne<") { - &ty["HasOne<".len()..(ty.len() - 1)] - } else if ty.starts_with("Option<") { - &ty["Option<".len()..(ty.len() - 1)] - } else if ty.starts_with("Vec<") { - &ty["Vec<".len()..(ty.len() - 1)] - } else { - panic!("Relation applied to non compound type: {ty}") +pub(crate) fn consume_meta(meta: ParseNestedMeta<'_>) { + let _ = meta.value().and_then(|v| v.parse::()); +} + +/// Remove ignored fields and compound fields +pub(crate) fn field_not_ignored(field: &Field) -> bool { + if let Type::Path(type_path) = &field.ty + && CompoundType::matches_type(type_path) + { + return false; } + + !field_ignored(field) } -pub(crate) fn format_field_ident(field: &Field) -> Ident { - field.ident.clone().unwrap() +fn field_ignored(field: &Field) -> bool { + let mut ignored = false; + + for attr in &field.attrs { + if !attr.path().is_ident("sea_orm") { + continue; + } + + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("ignore") { + ignored = true; + } else { + consume_meta(meta); + } + Ok(()) + }); + } + + ignored +} + +fn entity_generic_arg(args: &Punctuated) -> Option { + if args.len() != 1 { + return None; + } + match args.first() { + Some(GenericArgument::Type(Type::Path(type_path))) if is_entity_type(type_path) => { + Some(type_path.clone()) + } + _ => None, + } +} + +fn is_entity_type(type_path: &TypePath) -> bool { + last_path_segment(type_path).is_ok_and(|segment| segment.ident == "Entity") } pub(crate) fn trim_starting_raw_identifier(string: T) -> String @@ -260,6 +408,14 @@ impl GetMeta for Meta { } } +pub(crate) fn combine_error(acc: &mut Option, error: syn::Error) { + if let Some(acc) = acc { + acc.combine(error); + } else { + *acc = Some(error) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/sea-orm-macros/src/derives/value_type_match.rs b/sea-orm-macros/src/derives/value_type_match.rs index cfba21d052..b09af6e071 100644 --- a/sea-orm-macros/src/derives/value_type_match.rs +++ b/sea-orm-macros/src/derives/value_type_match.rs @@ -1,6 +1,6 @@ use proc_macro2::{Span, TokenStream}; use quote::quote_spanned; -use syn::{Ident, LitStr, Type}; +use syn::{GenericArgument, Ident, LitStr, PathArguments, Type, TypePath}; pub fn column_type_expr( column_type: Option, @@ -22,10 +22,16 @@ pub fn column_type_expr( pub fn column_type_wrapper( column_type: &Option, - field_type: &str, + field_type: &Type, field_span: Span, ) -> Option { - let nullable = trim_option(field_type).0; + let (nullable, field_type) = if let Type::Path(type_path) = field_type + && let Some(inner) = generic_type_arg(type_path, "Option") + { + (true, inner) + } else { + (false, field_type) + }; if let Some(column_type) = column_type { let column_type = if let Some((prefix, _)) = column_type.split_once('(') { @@ -67,65 +73,120 @@ pub fn column_type_wrapper( } } - match trim_option(field_type).1 { - "bool" => Some("BoolColumn"), - "String" => { - if nullable { - Some("StringColumnNullable") - } else { - Some("StringColumn") - } + let Type::Path(field_type) = field_type else { + return None; + }; + + let value_type = if is_type(field_type, "bool") { + Some("BoolColumn") + } else if is_type(field_type, "String") { + if nullable { + Some("StringColumnNullable") + } else { + Some("StringColumn") } - "Vec" => Some("BytesColumn"), - "Uuid" => Some("UuidColumn"), - "IpNetwork" => Some("IpNetworkColumn"), - "Json" | "serde_json::Value" => Some("JsonColumn"), - "TextUuid" => Some("TextUuidColumn"), - field_type => { - if is_numeric_column(field_type) || field_type.contains("UnixTimestamp") { - if nullable { - Some("NumericColumnNullable") - } else { - Some("NumericColumn") - } - } else if field_type.starts_with("Vec<") { - let field_type = &field_type["Vec<".len()..field_type.len() - 1]; - if is_numeric_column(field_type) { - Some("NumericArrayColumn") - } else { - Some("GenericArrayColumn") - } - } else if field_type.contains("DateTime") || field_type.contains("Timestamp") { - Some("DateTimeLikeColumn") - } else if field_type.contains("Date") { - Some("DateLikeColumn") - } else if field_type.contains("Time") { - Some("TimeLikeColumn") + } else if let Some(inner) = generic_type_arg(field_type, "Vec") { + if let Type::Path(inner) = inner { + if is_type(inner, "u8") { + Some("BytesColumn") + } else if is_numeric_type(inner) { + Some("NumericArrayColumn") } else { - None + Some("GenericArrayColumn") } + } else { + Some("GenericArrayColumn") + } + } else if is_type(field_type, "Uuid") { + Some("UuidColumn") + } else if is_type(field_type, "IpNetwork") { + Some("IpNetworkColumn") + } else if is_type(field_type, "Json") || is_serde_json_value(field_type) { + Some("JsonColumn") + } else if is_type(field_type, "TextUuid") { + Some("TextUuidColumn") + } else if is_numeric_type(field_type) || type_ident_contains(field_type, "UnixTimestamp") { + if nullable { + Some("NumericColumnNullable") + } else { + Some("NumericColumn") } + } else if type_ident_contains(field_type, "DateTime") + || type_ident_contains(field_type, "Timestamp") + { + Some("DateTimeLikeColumn") + } else if type_ident_contains(field_type, "Date") { + Some("DateLikeColumn") + } else if type_ident_contains(field_type, "Time") { + Some("TimeLikeColumn") + } else { + None + }; + + value_type.map(|ty| Ident::new(ty, field_span)) +} + +fn generic_type_arg<'a>(type_path: &'a TypePath, ident: &str) -> Option<&'a Type> { + let segment = type_path.path.segments.last()?; + if segment.ident != ident { + return None; } - .map(|ty| Ident::new(ty, field_span)) + let PathArguments::AngleBracketed(args) = &segment.arguments else { + return None; + }; + let Some(GenericArgument::Type(inner)) = args.args.first() else { + return None; + }; + Some(inner) +} + +fn is_type(type_path: &TypePath, ident: &str) -> bool { + type_path.path.segments.len() == 1 + && type_path + .path + .segments + .last() + .is_some_and(|segment| segment.ident == ident) +} + +fn type_ident_contains(type_path: &TypePath, pattern: &str) -> bool { + type_path + .path + .segments + .last() + .is_some_and(|segment| segment.ident.to_string().contains(pattern)) } -fn is_numeric_column(ty: &str) -> bool { +fn is_serde_json_value(type_path: &TypePath) -> bool { + let mut segments = type_path.path.segments.iter(); matches!( - ty, - "i8" | "i16" - | "i32" - | "i64" - | "u8" - | "u16" - | "u32" - | "u64" - | "f32" - | "f64" - | "Decimal" - | "BigDecimal" + (segments.next(), segments.next(), segments.next()), + (Some(first), Some(second), None) + if first.ident == "serde_json" && second.ident == "Value" ) } +fn is_numeric_type(type_path: &TypePath) -> bool { + if type_path.path.segments.len() != 1 { + return false; + } + + type_path.path.segments.last().is_some_and(|segment| { + segment.ident == "i8" + || segment.ident == "i16" + || segment.ident == "i32" + || segment.ident == "i64" + || segment.ident == "u8" + || segment.ident == "u16" + || segment.ident == "u32" + || segment.ident == "u64" + || segment.ident == "f32" + || segment.ident == "f64" + || segment.ident == "Decimal" + || segment.ident == "BigDecimal" + }) +} + pub fn array_type_expr( array_type: Option, field_type: &str, @@ -150,12 +211,3 @@ pub fn can_try_from_u64(field_type: &str) -> bool { "i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" ) } - -/// Return whether it is nullable -fn trim_option(s: &str) -> (bool, &str) { - if s.starts_with("Option<") { - (true, &s["Option<".len()..s.len() - 1]) - } else { - (false, s) - } -} diff --git a/sea-orm-sync/Cargo.toml b/sea-orm-sync/Cargo.toml index 3764fe18df..81125057df 100644 --- a/sea-orm-sync/Cargo.toml +++ b/sea-orm-sync/Cargo.toml @@ -40,6 +40,7 @@ bigdecimal = { version = "0.4", default-features = false, features = [ "std", ], optional = true } chrono = { version = "0.4.30", default-features = false, optional = true } +derive-where = "1.6.1" derive_more = { version = "2", features = ["debug"] } inventory = { version = "0.3", optional = true } ipnetwork = { version = "0.21", default-features = false, optional = true, features = [ diff --git a/sea-orm-sync/README.md b/sea-orm-sync/README.md index 3e2df1622b..532455fc19 100644 --- a/sea-orm-sync/README.md +++ b/sea-orm-sync/README.md @@ -91,7 +91,7 @@ mod post { pub user_id: i32, pub title: String, #[sea_orm(belongs_to, from = "user_id", to = "id")] - pub author: HasOne, + pub author: BelongsTo, #[sea_orm(has_many, via = "post_tag")] // M-N relation with junction pub tags: HasMany, } @@ -123,12 +123,9 @@ smart_user id: 42, name: "Bob".into(), email: "bob@sea-ql.org".into(), - profile: HasOne::Loaded( - profile::ModelEx { - picture: "image.jpg".into(), - } - .into(), - ), + profile: HasOne::loaded(Some(profile::ModelEx { + picture: "image.jpg".into(), + })), posts: HasMany::Loaded(vec![post::ModelEx { title: "Nice weather".into(), tags: HasMany::Loaded(vec![tag::ModelEx { diff --git a/sea-orm-sync/examples/quickstart/src/main.rs b/sea-orm-sync/examples/quickstart/src/main.rs index 7b412e486f..5e67bd80e1 100644 --- a/sea-orm-sync/examples/quickstart/src/main.rs +++ b/sea-orm-sync/examples/quickstart/src/main.rs @@ -38,7 +38,7 @@ mod profile { #[sea_orm(unique)] pub user_id: i32, #[sea_orm(belongs_to, from = "user_id", to = "id")] - pub user: HasOne, + pub user: BelongsTo, } impl ActiveModelBehavior for ActiveModel {} @@ -56,7 +56,7 @@ mod post { pub user_id: i32, pub title: String, #[sea_orm(belongs_to, from = "user_id", to = "id")] - pub author: HasOne, + pub author: BelongsTo, #[sea_orm(has_many)] pub comments: HasMany, #[sea_orm(has_many, via = "post_tag")] @@ -79,9 +79,9 @@ mod comment { pub user_id: i32, pub post_id: i32, #[sea_orm(belongs_to, from = "user_id", to = "id")] - pub user: HasOne, + pub user: BelongsTo, #[sea_orm(belongs_to, from = "post_id", to = "id")] - pub post: HasOne, + pub post: BelongsTo, } impl ActiveModelBehavior for ActiveModel {} @@ -117,9 +117,9 @@ mod post_tag { #[sea_orm(primary_key, auto_increment = false)] pub tag_id: i32, #[sea_orm(belongs_to, from = "post_id", to = "id")] - pub post: Option, + pub post: BelongsTo, #[sea_orm(belongs_to, from = "tag_id", to = "id")] - pub tag: Option, + pub tag: BelongsTo, } impl ActiveModelBehavior for ActiveModel {} @@ -137,14 +137,14 @@ mod user_follower { #[sea_orm(primary_key)] pub follower_id: i32, #[sea_orm(belongs_to, from = "user_id", to = "id")] - pub user: Option, + pub user: BelongsTo, #[sea_orm( belongs_to, relation_enum = "Follower", from = "follower_id", to = "id" )] - pub follower: Option, + pub follower: BelongsTo, } impl ActiveModelBehavior for ActiveModel {} diff --git a/sea-orm-sync/src/database/statement.rs b/sea-orm-sync/src/database/statement.rs index deafa01d3a..a8221973c3 100644 --- a/sea-orm-sync/src/database/statement.rs +++ b/sea-orm-sync/src/database/statement.rs @@ -86,10 +86,10 @@ impl fmt::Display for Statement { inject_parameters(&self.sql, &values.0, &SqliteQueryBuilder) } }; - write!(f, "{}", &string) + write!(f, "{string}") } None => { - write!(f, "{}", &self.sql) + write!(f, "{}", self.sql) } } } diff --git a/sea-orm-sync/src/entity/active_model.rs b/sea-orm-sync/src/entity/active_model.rs index 48a0063f65..d8e8f64e2a 100644 --- a/sea-orm-sync/src/entity/active_model.rs +++ b/sea-orm-sync/src/entity/active_model.rs @@ -783,8 +783,6 @@ pub trait ActiveModelTrait: Clone + Debug { } #[doc(hidden)] - /// Clear a specific belongs_to relation, keyed by relation (to disambiguate - /// multiple relations to the same entity), if it is optional; return true. fn clear_parent_key_for( &mut self, rel: ::Relation, @@ -905,16 +903,6 @@ pub trait ActiveModelTrait: Clone + Debug { Self::Entity::find_related().belongs_to_active_model(self) } - /// Like find_related, but infer type from `AM` - #[doc(hidden)] - fn find_related_of(&self, _: &[AM]) -> crate::query::Select - where - AM: ActiveModelTrait, - Self::Entity: Related, - { - self.find_related(AM::Entity::default()) - } - /// Establish links between self and a related Entity for a many-to-many relation. /// New associations will be added, and leftovers can be optionally deleted. #[doc(hidden)] diff --git a/sea-orm-sync/src/entity/active_model_ex.rs b/sea-orm-sync/src/entity/active_model_ex.rs index c5a7b0baa2..9dd3b92a18 100644 --- a/sea-orm-sync/src/entity/active_model_ex.rs +++ b/sea-orm-sync/src/entity/active_model_ex.rs @@ -1,25 +1,43 @@ -use super::compound::{HasMany, HasOne}; +use super::compound::{BelongsTo, BelongsToCardinality, HasMany, HasOne}; use crate::{ActiveModelTrait, DbErr, EntityTrait, ModelTrait, TryIntoModel}; use core::ops::{Index, IndexMut}; -/// State carried by a `belongs_to` or `has_one` field on an +/// State carried by a `belongs_to` field on an /// [`ActiveModelEx`](crate::EntityTrait::ActiveModelEx). Mirrors the /// `NotSet` / `Set` shape of [`ActiveValue`](crate::ActiveValue) but for a /// related model. /// /// Unstable: nested-`ActiveModel` relation mutation is exempt from semver — the -/// semantics of replacing or removing related records may change in a minor (2.x) release. -#[derive(Debug, Default, Clone)] -#[non_exhaustive] -pub enum ActiveHasOne { +/// semantics of setting or removing related records may change in a minor (2.x) release. +#[derive_where::derive_where(Debug, Clone, PartialEq, Eq; ::Set)] +#[derive(Default)] +pub enum ActiveBelongsTo +where + T: BelongsToCardinality, +{ /// Field is absent; the related model is left as-is on save. #[default] NotSet, - /// Assign this related ActiveModel on save. Any existing linked record is - /// replaced (the old one is deleted or orphaned, then this one is written). - Set(Box), - /// Delete (or orphan, if the foreign key is nullable) the existing linked record on save. - Delete, + /// Set the related ActiveModel on save. + Set(::Set), +} + +/// State carried by a `has_one` field on an +/// [`ActiveModelEx`](crate::EntityTrait::ActiveModelEx). +/// +/// Unstable: nested-`ActiveModel` relation mutation is exempt from semver — the +/// semantics of setting or removing related records may change in a minor (2.x) release. +#[derive_where::derive_where(Debug, Clone, PartialEq, Eq; E::ActiveModelEx)] +#[derive(Default)] +pub enum ActiveHasOne +where + E: EntityTrait, +{ + /// Field is absent; the related model is left as-is on save. + #[default] + NotSet, + /// Set or clear the related ActiveModel on save. + Set(Option>), } /// State carried by a `has_many` (or many-to-many) field on an @@ -28,7 +46,8 @@ pub enum ActiveHasOne { /// /// Unstable: nested-`ActiveModel` relation mutation is exempt from semver — the /// semantics of replacing or removing related records may change in a minor (2.x) release. -#[derive(Debug, Default, Clone)] +#[derive_where::derive_where(Debug, Clone, PartialEq, Eq; E::ActiveModelEx)] +#[derive(Default)] #[non_exhaustive] pub enum ActiveHasMany { /// Field is absent; existing related models are left as-is on save. @@ -56,28 +75,39 @@ pub enum ActiveModelAction { Save, } -impl ActiveHasOne +impl ActiveBelongsTo where - E: EntityTrait, + T: BelongsToCardinality, { - /// Construct a `ActiveHasOne::Set` - pub fn set>(model: AM) -> Self { - Self::Set(Box::new(model.into())) + /// Construct an `ActiveBelongsTo::Set`. Accepts a bare active model for a + /// required relation, or an `Option` for an optional one. + pub fn set(model: T::Value) -> Self + where + M: Into<<::Entity as EntityTrait>::ActiveModelEx>, + { + Self::Set(T::into_set(model)) } - /// Replace the inner Model - pub fn replace>(&mut self, model: AM) { - *self = Self::Set(Box::new(model.into())); + /// Take ownership of this relation state, leaving `NotSet` in place + pub fn take(&mut self) -> Self { + std::mem::take(self) } - /// Take ownership of this model, leaving `NotSet` in place - pub fn take(&mut self) -> Option { - match std::mem::take(self) { - Self::Set(model) => Some(*model), - _ => None, - } + /// Return true if self is NotSet + pub fn is_not_set(&self) -> bool { + matches!(self, Self::NotSet) } + /// Return true if there is a set value + pub fn is_set(&self) -> bool { + matches!(self, Self::Set(_)) + } +} + +impl ActiveBelongsTo +where + E: EntityTrait, +{ /// Get a reference, if set pub fn as_ref(&self) -> Option<&E::ActiveModelEx> { match self { @@ -95,21 +125,6 @@ where } } - /// Return true if there is a model - pub fn is_set(&self) -> bool { - matches!(self, Self::Set(_)) - } - - /// Return true if self is NotSet - pub fn is_not_set(&self) -> bool { - matches!(self, Self::NotSet) - } - - /// Return true if self is NotSet - pub fn is_none(&self) -> bool { - matches!(self, Self::NotSet) - } - /// Return true if the containing model is set and changed pub fn is_changed(&self) -> bool { match self { @@ -118,94 +133,171 @@ where } } - /// Return true if self is `Delete` - pub fn is_delete(&self) -> bool { - matches!(self, Self::Delete) + /// Convert into an `Option` + pub fn into_option(self) -> Option { + match self { + Self::Set(model) => Some(*model), + Self::NotSet => None, + } } - /// Borrow the set model as a slice (length 0 or 1); used for type inference - /// and primary-key comparison against the live database. - #[doc(hidden)] - pub fn as_slice(&self) -> &[E::ActiveModelEx] { + /// Convert this back to a `ModelEx` container + pub fn try_into_model(self) -> Result, DbErr> + where + E::ActiveModelEx: TryIntoModel, + { + Ok(match self { + Self::Set(model) => BelongsTo::Loaded(Box::new((*model).try_into_model()?)), + Self::NotSet => BelongsTo::Unloaded, + }) + } +} + +impl ActiveBelongsTo> +where + E: EntityTrait, +{ + /// Get a reference, if set + pub fn as_ref(&self) -> Option<&E::ActiveModelEx> { match self { - Self::Set(model) => std::slice::from_ref(model.as_ref()), - _ => &[], + Self::Set(Some(model)) => Some(model), + _ => None, } } - /// Return true if the set model's primary key matches `model`. - pub fn find(&self, model: &E::Model) -> bool { - let pk = model.get_primary_key_value(); - for item in self.as_slice() { - if let Some(pk_item) = item.get_primary_key_value() - && pk_item == pk - { - return true; - } + /// Get a mutable reference, if set + #[allow(clippy::should_implement_trait)] + pub fn as_mut(&mut self) -> Option<&mut E::ActiveModelEx> { + match self { + Self::Set(Some(model)) => Some(model), + _ => None, } - false } - /// Convert into an `Option` - pub fn into_option(self) -> Option { + /// Return true if the containing model is set and changed + pub fn is_changed(&self) -> bool { match self { - Self::Set(model) => Some(*model), - Self::NotSet | Self::Delete => None, + Self::Set(Some(model)) => model.is_changed(), + Self::Set(None) => true, + Self::NotSet => false, } } - /// For type inference purpose - #[doc(hidden)] - pub fn empty_slice(&self) -> &[E::ActiveModelEx] { - &[] + /// Convert into an `Option` + pub fn into_option(self) -> Option { + match self { + Self::Set(Some(model)) => Some(*model), + Self::Set(None) | Self::NotSet => None, + } } /// Convert this back to a `ModelEx` container - pub fn try_into_model(self) -> Result, DbErr> + pub fn try_into_model(self) -> Result>, DbErr> where E::ActiveModelEx: TryIntoModel, { Ok(match self { - Self::Set(model) => HasOne::Loaded(Box::new((*model).try_into_model()?)), - Self::NotSet | Self::Delete => HasOne::Unloaded, + Self::Set(Some(model)) => BelongsTo::Loaded(Some(Box::new((*model).try_into_model()?))), + Self::Set(None) => BelongsTo::Loaded(None), + Self::NotSet => BelongsTo::Unloaded, }) } } -impl PartialEq for ActiveHasOne +impl PartialEq> for ActiveBelongsTo> where E: EntityTrait, E::ActiveModelEx: PartialEq, { - fn eq(&self, other: &Self) -> bool { + fn eq(&self, other: &Option) -> bool { match (self, other) { - (ActiveHasOne::NotSet, ActiveHasOne::NotSet) => true, - (ActiveHasOne::Set(a), ActiveHasOne::Set(b)) => a == b, - (ActiveHasOne::Delete, ActiveHasOne::Delete) => true, + (Self::NotSet, None) => true, + (Self::Set(Some(a)), Some(b)) => a.as_ref() == b, + (Self::Set(None), None) => true, _ => false, } } } -impl PartialEq> for ActiveHasOne +impl ActiveHasOne where E: EntityTrait, - E::ActiveModelEx: PartialEq, { - fn eq(&self, other: &Option) -> bool { - match (self, other) { - (ActiveHasOne::NotSet, None) => true, - (ActiveHasOne::Set(a), Some(b)) => a.as_ref() == b, - _ => false, + /// Construct an `ActiveHasOne::Set` from an optional related active model. + pub fn set(model: Option>) -> Self { + Self::Set(model.map(|model| Box::new(model.into()))) + } + + /// Return true if self is NotSet + pub fn is_not_set(&self) -> bool { + matches!(self, Self::NotSet) + } + + /// Return true if there is a set value + pub fn is_set(&self) -> bool { + matches!(self, Self::Set(_)) + } + + /// Get a reference, if set + pub fn as_ref(&self) -> Option<&E::ActiveModelEx> { + match self { + Self::Set(Some(model)) => Some(model), + _ => None, } } + + /// Get a mutable reference, if set + #[allow(clippy::should_implement_trait)] + pub fn as_mut(&mut self) -> Option<&mut E::ActiveModelEx> { + match self { + Self::Set(Some(model)) => Some(model), + _ => None, + } + } + + /// Return true if the containing model is set and changed + pub fn is_changed(&self) -> bool { + match self { + Self::Set(Some(model)) => model.is_changed(), + Self::Set(None) => true, + Self::NotSet => false, + } + } + + /// Convert into an `Option` + pub fn into_option(self) -> Option { + match self { + Self::Set(Some(model)) => Some(*model), + Self::Set(None) | Self::NotSet => None, + } + } + + /// Convert this back to a `ModelEx` container + pub fn try_into_model(self) -> Result, DbErr> + where + E::ActiveModelEx: TryIntoModel, + { + Ok(match self { + Self::Set(Some(model)) => HasOne::Loaded(Some(Box::new((*model).try_into_model()?))), + Self::Set(None) => HasOne::Loaded(None), + Self::NotSet => HasOne::Unloaded, + }) + } } -impl Eq for ActiveHasOne +impl PartialEq> for ActiveHasOne where E: EntityTrait, - E::ActiveModelEx: Eq, + E::ActiveModelEx: PartialEq, { + fn eq(&self, other: &Option) -> bool { + match (self, other) { + (Self::NotSet, None) => true, + (Self::Set(Some(a)), Some(b)) => a.as_ref() == b, + (Self::Set(None), None) => true, + _ => false, + } + } } impl ActiveHasMany @@ -350,28 +442,6 @@ where } } -impl PartialEq for ActiveHasMany -where - E: EntityTrait, - E::ActiveModelEx: PartialEq, -{ - fn eq(&self, other: &Self) -> bool { - match (self, other) { - (ActiveHasMany::NotSet, ActiveHasMany::NotSet) => true, - (ActiveHasMany::Replace(a), ActiveHasMany::Replace(b)) => a == b, - (ActiveHasMany::Append(a), ActiveHasMany::Append(b)) => a == b, - _ => false, - } - } -} - -impl Eq for ActiveHasMany -where - E: EntityTrait, - E::ActiveModelEx: Eq, -{ -} - impl From> for Option> { fn from(value: ActiveHasMany) -> Self { match value { diff --git a/sea-orm-sync/src/entity/compound.rs b/sea-orm-sync/src/entity/compound.rs index 0efbfbad4f..0fde6a1aea 100644 --- a/sea-orm-sync/src/entity/compound.rs +++ b/sea-orm-sync/src/entity/compound.rs @@ -7,9 +7,11 @@ use crate::{ use sea_query::{IntoValueTuple, Order, TableRef}; use std::marker::PhantomData; +mod belongs_to; mod has_many; mod has_one; +pub use belongs_to::{BelongsTo, BelongsToCardinality}; pub use has_many::{HasMany, Iter as HasManyIter}; pub use has_one::HasOne; diff --git a/sea-orm-sync/src/entity/compound/belongs_to.rs b/sea-orm-sync/src/entity/compound/belongs_to.rs new file mode 100644 index 0000000000..0c75ceeae4 --- /dev/null +++ b/sea-orm-sync/src/entity/compound/belongs_to.rs @@ -0,0 +1,344 @@ +use crate::{ActiveBelongsTo, EntityTrait}; + +#[doc(hidden)] +pub trait BelongsToCardinality { + type Entity: EntityTrait; + type Loaded; + type Set; + type Value; + + fn into_loaded(value: Self::Value) -> Self::Loaded + where + M: Into<::ModelEx>; + + fn into_set(value: Self::Value) -> Self::Set + where + M: Into<::ActiveModelEx>; +} + +impl BelongsToCardinality for E +where + E: EntityTrait, +{ + type Entity = E; + type Loaded = Box; + type Set = Box; + type Value = M; + + fn into_loaded(value: M) -> Self::Loaded + where + M: Into, + { + Box::new(value.into()) + } + + fn into_set(value: M) -> Self::Set + where + M: Into, + { + Box::new(value.into()) + } +} + +impl BelongsToCardinality for Option +where + E: EntityTrait, +{ + type Entity = E; + type Loaded = Option>; + type Set = Option>; + type Value = Option; + + fn into_loaded(value: Option) -> Self::Loaded + where + M: Into, + { + value.map(|model| Box::new(model.into())) + } + + fn into_set(value: Option) -> Self::Set + where + M: Into, + { + value.map(|model| Box::new(model.into())) + } +} + +#[derive_where::derive_where( + Debug, Clone, PartialEq, Eq, Hash; + ::Loaded +)] +#[derive(Default)] +pub enum BelongsTo +where + T: BelongsToCardinality, +{ + #[default] + Unloaded, + Loaded(::Loaded), +} + +impl BelongsTo +where + T: BelongsToCardinality, +{ + pub fn loaded(model: T::Value) -> Self + where + M: Into<<::Entity as EntityTrait>::ModelEx>, + { + Self::Loaded(T::into_loaded(model)) + } + + /// Return true if variant is `Unloaded` + pub fn is_unloaded(&self) -> bool { + matches!(self, Self::Unloaded) + } + + /// Return true if variant is `Loaded` + pub fn is_loaded(&self) -> bool { + matches!(self, Self::Loaded(_)) + } +} + +impl BelongsTo +where + E: EntityTrait, +{ + /// Get a reference, if loaded + pub fn as_ref(&self) -> Option<&E::ModelEx> { + match self { + Self::Loaded(model) => Some(model.as_ref()), + Self::Unloaded => None, + } + } + + /// Get a mutable reference, if loaded + pub fn as_mut(&mut self) -> Option<&mut E::ModelEx> { + match self { + Self::Loaded(model) => Some(model), + Self::Unloaded => None, + } + } + + /// Convert into an `Option` + pub fn into_option(self) -> Option { + match self { + Self::Loaded(model) => Some(*model), + Self::Unloaded => None, + } + } + + /// # Panics + /// + /// Panics if called on an `Unloaded` value. + pub fn unwrap(self) -> E::ModelEx { + match self { + Self::Loaded(model) => *model, + Self::Unloaded => panic!("called `BelongsTo::unwrap()` on an `Unloaded` value"), + } + } +} + +impl BelongsTo> +where + E: EntityTrait, +{ + /// Return true if this optional relation was loaded and no model was found. + pub fn is_not_found(&self) -> bool { + matches!(self, Self::Loaded(None)) + } + + /// Get a reference, if loaded with a model + pub fn as_ref(&self) -> Option<&E::ModelEx> { + match self { + Self::Loaded(Some(model)) => Some(model.as_ref()), + Self::Unloaded | Self::Loaded(None) => None, + } + } + + /// Get a mutable reference, if loaded with a model + pub fn as_mut(&mut self) -> Option<&mut E::ModelEx> { + match self { + Self::Loaded(Some(model)) => Some(model), + Self::Unloaded | Self::Loaded(None) => None, + } + } + + /// Convert into an `Option` + pub fn into_option(self) -> Option { + match self { + Self::Loaded(Some(model)) => Some(*model), + Self::Unloaded | Self::Loaded(None) => None, + } + } + + /// # Panics + /// + /// Panics if called on an `Unloaded` or `Loaded(None)` value. + pub fn unwrap(self) -> E::ModelEx { + match self { + Self::Loaded(Some(model)) => *model, + Self::Unloaded => panic!("called `BelongsTo::unwrap()` on an `Unloaded` value"), + Self::Loaded(None) => panic!("called `BelongsTo::unwrap()` on a `Loaded(None)` value"), + } + } +} + +impl BelongsTo +where + E: EntityTrait, + E::ActiveModelEx: From, +{ + pub fn into_active_model(self) -> ActiveBelongsTo { + match self { + Self::Loaded(model) => ActiveBelongsTo::set(*model), + Self::Unloaded => ActiveBelongsTo::NotSet, + } + } +} + +impl BelongsTo> +where + E: EntityTrait, + E::ActiveModelEx: From, +{ + pub fn into_active_model(self) -> ActiveBelongsTo> { + match self { + Self::Loaded(Some(model)) => ActiveBelongsTo::set(Some(*model)), + Self::Unloaded | Self::Loaded(None) => ActiveBelongsTo::NotSet, + } + } +} + +impl From> for Option> { + fn from(value: BelongsTo) -> Self { + match value { + BelongsTo::Loaded(model) => Some(model), + BelongsTo::Unloaded => None, + } + } +} + +impl From>> for Option> { + fn from(value: BelongsTo>) -> Self { + match value { + BelongsTo::Loaded(model) => model, + BelongsTo::Unloaded => None, + } + } +} + +impl From>> for BelongsTo { + fn from(value: Option>) -> Self { + match value { + Some(model) => Self::Loaded(model), + None => Self::Unloaded, + } + } +} + +impl From>> for BelongsTo> { + fn from(value: Option>) -> Self { + Self::Loaded(value) + } +} + +impl PartialEq>> for BelongsTo +where + E: EntityTrait, + E::ModelEx: PartialEq, +{ + fn eq(&self, other: &Option>) -> bool { + match (self, other) { + (Self::Loaded(a), Some(b)) => a.as_ref() == b.as_ref(), + (Self::Unloaded, None) => true, + _ => false, + } + } +} + +impl PartialEq>> for BelongsTo> +where + E: EntityTrait, + E::ModelEx: PartialEq, +{ + fn eq(&self, other: &Option>) -> bool { + match (self, other) { + (Self::Loaded(a), b) => a == b, + (Self::Unloaded, None) => true, + _ => false, + } + } +} + +impl PartialEq> for Option> +where + E: EntityTrait, + E::ModelEx: PartialEq, +{ + fn eq(&self, other: &BelongsTo) -> bool { + other == self + } +} + +impl PartialEq>> for Option> +where + E: EntityTrait, + E::ModelEx: PartialEq, +{ + fn eq(&self, other: &BelongsTo>) -> bool { + other == self + } +} + +#[cfg(feature = "with-json")] +impl serde::Serialize for BelongsTo +where + T: BelongsToCardinality, + ::Loaded: serde::Serialize, +{ + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + BelongsTo::Unloaded => None, + BelongsTo::Loaded(model) => Some(model), + } + .serialize(serializer) + } +} + +#[cfg(feature = "with-json")] +impl<'de, E> serde::Deserialize<'de> for BelongsTo +where + E: EntityTrait, + E::ModelEx: serde::Deserialize<'de>, +{ + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + match >::deserialize(deserializer)? { + Some(model) => Ok(BelongsTo::Loaded(Box::new(model))), + None => Ok(BelongsTo::Unloaded), + } + } +} + +#[cfg(feature = "with-json")] +impl<'de, E> serde::Deserialize<'de> for BelongsTo> +where + E: EntityTrait, + E::ModelEx: serde::Deserialize<'de>, +{ + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + match >::deserialize(deserializer)? { + Some(model) => Ok(BelongsTo::Loaded(Some(Box::new(model)))), + None => Ok(BelongsTo::Unloaded), + } + } +} diff --git a/sea-orm-sync/src/entity/compound/has_many.rs b/sea-orm-sync/src/entity/compound/has_many.rs index 8e963206bc..0637afd6f9 100644 --- a/sea-orm-sync/src/entity/compound/has_many.rs +++ b/sea-orm-sync/src/entity/compound/has_many.rs @@ -353,14 +353,11 @@ mod test { let mut cake = cake::ModelEx { id: 1, name: "A".into(), - fruit: HasOne::Loaded( - fruit::ModelEx { - id: 2, - name: "B".into(), - cake_id: None, - } - .into(), - ), + fruit: HasOne::loaded(Some(fruit::ModelEx { + id: 2, + name: "B".into(), + cake_id: None, + })), fillings: HasMany::Unloaded, }; @@ -378,14 +375,11 @@ mod test { let cake = cake::ModelEx { id: 1, name: "A".into(), - fruit: HasOne::Loaded( - fruit::ModelEx { - id: 0, - name: "B".into(), - cake_id: None, - } - .into(), - ), + fruit: HasOne::loaded(Some(fruit::ModelEx { + id: 0, + name: "B".into(), + cake_id: None, + })), fillings: HasMany::Loaded(vec![ filling::Model { id: 2, diff --git a/sea-orm-sync/src/entity/compound/has_one.rs b/sea-orm-sync/src/entity/compound/has_one.rs index 0d729f77a0..e0f4cd952f 100644 --- a/sea-orm-sync/src/entity/compound/has_one.rs +++ b/sea-orm-sync/src/entity/compound/has_one.rs @@ -1,77 +1,81 @@ use crate::{ActiveHasOne, EntityTrait}; -use std::hash::{Hash, Hasher}; -#[derive(Debug, Default, Clone)] -pub enum HasOne { +#[derive_where::derive_where(Debug, Clone, PartialEq, Eq, Hash; E::ModelEx)] +#[derive(Default)] +pub enum HasOne +where + E: EntityTrait, +{ #[default] Unloaded, - NotFound, - Loaded(Box), + Loaded(Option>), } -impl HasOne { - /// Construct a `HasOne::Loaded` value - pub fn loaded>(model: M) -> Self { - Self::Loaded(Box::new(model.into())) +impl HasOne +where + E: EntityTrait, +{ + pub fn loaded(model: Option>) -> Self { + Self::Loaded(model.map(|model| Box::new(model.into()))) } /// Return true if variant is `Unloaded` pub fn is_unloaded(&self) -> bool { - matches!(self, HasOne::Unloaded) - } - - /// Return true if variant is `NotFound` - pub fn is_not_found(&self) -> bool { - matches!(self, HasOne::NotFound) + matches!(self, Self::Unloaded) } /// Return true if variant is `Loaded` pub fn is_loaded(&self) -> bool { - matches!(self, HasOne::Loaded(_)) + matches!(self, Self::Loaded(_)) } - /// True if variant is `Unloaded` or `NotFound` + /// Return true if this relation was loaded and no model was found. + pub fn is_not_found(&self) -> bool { + matches!(self, Self::Loaded(None)) + } + + /// True if variant is `Unloaded` or `Loaded(None)` pub fn is_none(&self) -> bool { - matches!(self, HasOne::Unloaded | HasOne::NotFound) + matches!(self, Self::Unloaded | Self::Loaded(None)) } - /// Get a reference, if loaded + /// Get a reference, if loaded with a model pub fn as_ref(&self) -> Option<&E::ModelEx> { match self { - HasOne::Loaded(model) => Some(model.as_ref()), - HasOne::Unloaded | HasOne::NotFound => None, + Self::Loaded(Some(model)) => Some(model.as_ref()), + Self::Unloaded | Self::Loaded(None) => None, } } - /// Get a mutable reference, if loaded + /// Get a mutable reference, if loaded with a model pub fn as_mut(&mut self) -> Option<&mut E::ModelEx> { match self { - HasOne::Loaded(model) => Some(model), - HasOne::Unloaded | HasOne::NotFound => None, + Self::Loaded(Some(model)) => Some(model), + Self::Unloaded | Self::Loaded(None) => None, } } /// Convert into an `Option` pub fn into_option(self) -> Option { match self { - HasOne::Loaded(model) => Some(*model), - HasOne::Unloaded | HasOne::NotFound => None, + Self::Loaded(Some(model)) => Some(*model), + Self::Unloaded | Self::Loaded(None) => None, } } - /// Take ownership of the contained Model, leaving `Unloaded` in place. + /// Take ownership of the contained Model, if loaded, leaving `Unloaded` in place. pub fn take(&mut self) -> Option { std::mem::take(self).into_option() } /// # Panics /// - /// Panics if called on `Unloaded` or `NotFound` values. + /// Panics if called on an `Unloaded` or `Loaded(None)` value. pub fn unwrap(self) -> E::ModelEx { match self { - HasOne::Loaded(model) => *model, - HasOne::Unloaded => panic!("called `HasOne::unwrap()` on an `Unloaded` value"), - HasOne::NotFound => panic!("called `HasOne::unwrap()` on a `NotFound` value"), + Self::Loaded(Some(model)) => *model, + Self::Unloaded => panic!("called `HasOne::unwrap()` on an `Unloaded` value"), + Self::Loaded(None) => panic!("called `HasOne::unwrap()` on a `Loaded(None)` value"), } } } @@ -83,55 +87,24 @@ where { pub fn into_active_model(self) -> ActiveHasOne { match self { - HasOne::Loaded(_) => { - let model = self.unwrap(); - let active_model: E::ActiveModelEx = model.into(); - ActiveHasOne::Set(active_model.into()) - } - HasOne::Unloaded => ActiveHasOne::NotSet, - HasOne::NotFound => ActiveHasOne::NotSet, + Self::Loaded(Some(model)) => ActiveHasOne::set(Some(*model)), + Self::Unloaded | Self::Loaded(None) => ActiveHasOne::NotSet, } } } -impl PartialEq for HasOne -where - E: EntityTrait, - E::ModelEx: PartialEq, -{ - fn eq(&self, other: &Self) -> bool { - match (self, other) { - (HasOne::Unloaded, HasOne::Unloaded) => true, - (HasOne::NotFound, HasOne::NotFound) => true, - (HasOne::Loaded(a), HasOne::Loaded(b)) => a == b, - _ => false, - } - } -} - -impl Eq for HasOne -where - E: EntityTrait, - E::ModelEx: Eq, -{ -} - -// Option>> <-> HasOne conversions and comparisons impl From> for Option> { fn from(value: HasOne) -> Self { match value { - HasOne::Loaded(model) => Some(model), - HasOne::Unloaded | HasOne::NotFound => None, + HasOne::Loaded(model) => model, + HasOne::Unloaded => None, } } } impl From>> for HasOne { fn from(value: Option>) -> Self { - match value { - Some(model) => HasOne::Loaded(model), - None => HasOne::NotFound, - } + Self::Loaded(value) } } @@ -142,8 +115,8 @@ where { fn eq(&self, other: &Option>) -> bool { match (self, other) { - (HasOne::Loaded(a), Some(b)) => a.as_ref() == b.as_ref(), - (HasOne::Unloaded | HasOne::NotFound, None) => true, + (Self::Loaded(a), b) => a == b, + (Self::Unloaded, None) => true, _ => false, } } @@ -159,21 +132,6 @@ where } } -impl Hash for HasOne -where - E: EntityTrait, - E::ModelEx: Hash, -{ - fn hash(&self, state: &mut H) { - std::mem::discriminant(self).hash(state); - match self { - Self::Loaded(model) => model.hash(state), - Self::Unloaded => {} - Self::NotFound => {} - } - } -} - #[cfg(feature = "with-json")] impl serde::Serialize for HasOne where @@ -186,8 +144,7 @@ where { match self { HasOne::Unloaded => None, - HasOne::NotFound => None, - HasOne::Loaded(model) => Some(model), + HasOne::Loaded(model) => model.as_ref(), } .serialize(serializer) } @@ -204,7 +161,7 @@ where D: serde::Deserializer<'de>, { match >::deserialize(deserializer)? { - Some(model) => Ok(HasOne::Loaded(Box::new(model))), + Some(model) => Ok(HasOne::Loaded(Some(Box::new(model)))), None => Ok(HasOne::Unloaded), } } diff --git a/sea-orm-sync/src/entity/mod.rs b/sea-orm-sync/src/entity/mod.rs index d19cd272b3..1f46b9a909 100644 --- a/sea-orm-sync/src/entity/mod.rs +++ b/sea-orm-sync/src/entity/mod.rs @@ -40,7 +40,7 @@ //! # pub id: i32, //! # pub cake_id: Option, //! # #[sea_orm(belongs_to, from = "cake_id", to = "id")] -//! # pub cake: HasOne, +//! # pub cake: BelongsTo>, //! # } //! # impl ActiveModelBehavior for ActiveModel {} //! # } @@ -159,7 +159,7 @@ pub use arrow_schema::*; pub use base_entity::*; pub use column::*; pub use column_def::*; -pub use compound::EntityLoaderTrait; +pub use compound::{BelongsToCardinality, EntityLoaderTrait}; pub use identity::*; pub use link::*; pub use model::*; diff --git a/sea-orm-sync/src/entity/model.rs b/sea-orm-sync/src/entity/model.rs index 44c3a608d9..0935153142 100644 --- a/sea-orm-sync/src/entity/model.rs +++ b/sea-orm-sync/src/entity/model.rs @@ -314,11 +314,11 @@ mod tests { let cake_ex = cake::ModelEx { id: 12, name: "C".into(), - fruit: HasOne::loaded(fruit::Model { + fruit: HasOne::loaded(Some(fruit::Model { id: 13, name: "F".into(), cake_id: Some(12), - }), + })), fillings: HasMany::Loaded(vec![ filling::Model { id: 14, @@ -333,14 +333,14 @@ mod tests { let cake_am = cake::ActiveModelEx { id: Unchanged(12), name: Unchanged("C".into()), - fruit: ActiveHasOne::Set( + fruit: ActiveHasOne::Set(Some( fruit::ActiveModelEx { id: Unchanged(13), name: Unchanged("F".into()), cake_id: Unchanged(Some(12)), } .into(), - ), + )), fillings: ActiveHasMany::Append(vec![filling::ActiveModelEx { id: Unchanged(14), name: Unchanged("FF".into()), diff --git a/sea-orm-sync/src/entity/prelude.rs b/sea-orm-sync/src/entity/prelude.rs index e5eeab9e70..a45b988a14 100644 --- a/sea-orm-sync/src/entity/prelude.rs +++ b/sea-orm-sync/src/entity/prelude.rs @@ -16,8 +16,8 @@ pub use crate::{ DeriveRelatedEntity, DeriveRelation, DeriveValueType, FromJsonQueryResult, }; -pub use super::active_model_ex::{ActiveHasMany, ActiveHasOne}; -pub use super::compound::{HasMany, HasOne}; +pub use super::active_model_ex::{ActiveBelongsTo, ActiveHasMany, ActiveHasOne}; +pub use super::compound::{BelongsTo, HasMany, HasOne}; #[cfg(not(feature = "sync"))] pub use async_trait; diff --git a/sea-orm-sync/src/lib.rs b/sea-orm-sync/src/lib.rs index 13869c3edf..fa601e2713 100644 --- a/sea-orm-sync/src/lib.rs +++ b/sea-orm-sync/src/lib.rs @@ -85,7 +85,7 @@ //! # #[sea_orm(unique)] //! # pub user_id: i32, //! # #[sea_orm(belongs_to, from = "user_id", to = "id")] -//! # pub user: HasOne, +//! # pub user: BelongsTo, //! # } //! # impl ActiveModelBehavior for ActiveModel {} //! # } @@ -113,9 +113,9 @@ //! # #[sea_orm(primary_key, auto_increment = false)] //! # pub tag_id: i32, //! # #[sea_orm(belongs_to, from = "post_id", to = "id")] -//! # pub post: Option, +//! # pub post: BelongsTo, //! # #[sea_orm(belongs_to, from = "tag_id", to = "id")] -//! # pub tag: Option, +//! # pub tag: BelongsTo, //! # } //! # impl ActiveModelBehavior for ActiveModel {} //! # } @@ -150,7 +150,7 @@ //! pub user_id: i32, //! pub title: String, //! #[sea_orm(belongs_to, from = "user_id", to = "id")] -//! pub author: HasOne, +//! pub author: BelongsTo, //! #[sea_orm(has_many, via = "post_tag")] // M-N relation with junction //! pub tags: HasMany, //! } @@ -186,20 +186,17 @@ //! id: 42, //! name: "Bob".into(), //! email: "bob@sea-ql.org".into(), -//! profile: HasOne::Loaded( -//! profile::ModelEx { +//! profile: HasOne::loaded(Some(profile::ModelEx { //! # id: 1, -//! picture: "image.jpg".into(), +//! picture: "image.jpg".into(), //! # user_id: 1, -//! # user: HasOne::Unloaded, -//! } -//! .into(), -//! ), +//! # user: BelongsTo::Unloaded, +//! })), //! posts: HasMany::Loaded(vec![post::ModelEx { //! # id: 2, //! # user_id: 1, //! title: "Nice weather".into(), -//! # author: HasOne::Unloaded, +//! # author: BelongsTo::Unloaded, //! # comments: HasMany::Unloaded, //! tags: HasMany::Loaded(vec![tag::ModelEx { //! # id: 3, diff --git a/sea-orm-sync/src/query/util.rs b/sea-orm-sync/src/query/util.rs index 0206b764ec..3b8a26b634 100644 --- a/sea-orm-sync/src/query/util.rs +++ b/sea-orm-sync/src/query/util.rs @@ -126,10 +126,6 @@ where if !column.def().is_null() { return Ok(false); } - // Null out the key column so the detach is persisted. If it was never set - // (a freshly-built ActiveModel) there is nothing to clear — it will insert as - // NULL / stay absent on update — so treat it as already cleared rather than - // erroring. if let Some(value) = model.get(column).into_value() { model.set(column, value.as_null()); } diff --git a/sea-orm-sync/src/tests_cfg/comment.rs b/sea-orm-sync/src/tests_cfg/comment.rs index 24d52888cc..9d3c10fb5d 100644 --- a/sea-orm-sync/src/tests_cfg/comment.rs +++ b/sea-orm-sync/src/tests_cfg/comment.rs @@ -11,9 +11,9 @@ pub struct Model { pub user_id: i32, pub post_id: i32, #[sea_orm(belongs_to, from = "user_id", to = "id")] - pub user: HasOne, + pub user: BelongsTo, #[sea_orm(belongs_to, from = "post_id", to = "id")] - pub post: HasOne, + pub post: BelongsTo, } impl ActiveModelBehavior for ActiveModel {} diff --git a/sea-orm-sync/src/tests_cfg/ingredient.rs b/sea-orm-sync/src/tests_cfg/ingredient.rs index e256a5bb47..32f01e9c71 100644 --- a/sea-orm-sync/src/tests_cfg/ingredient.rs +++ b/sea-orm-sync/src/tests_cfg/ingredient.rs @@ -15,14 +15,14 @@ pub struct Model { pub filling_id: Option, pub ingredient_id: Option, #[sea_orm(belongs_to, from = "filling_id", to = "id")] - pub filling: HasOne, + pub filling: BelongsTo, #[sea_orm( self_ref, relation_enum = "Ingredient", from = "IngredientId", to = "Id" )] - pub ingredient: HasOne, + pub ingredient: BelongsTo, } impl ActiveModelBehavior for ActiveModel {} diff --git a/sea-orm-sync/src/tests_cfg/post.rs b/sea-orm-sync/src/tests_cfg/post.rs index 7c12fa9620..90eb93d19e 100644 --- a/sea-orm-sync/src/tests_cfg/post.rs +++ b/sea-orm-sync/src/tests_cfg/post.rs @@ -10,7 +10,7 @@ pub struct Model { pub user_id: i32, pub title: String, #[sea_orm(belongs_to, from = "user_id", to = "id")] - pub author: HasOne, + pub author: BelongsTo, #[sea_orm(has_many)] pub comments: HasMany, #[sea_orm(has_many, via = "post_tag")] diff --git a/sea-orm-sync/src/tests_cfg/post_tag.rs b/sea-orm-sync/src/tests_cfg/post_tag.rs index e0b33acd3c..8ef4816912 100644 --- a/sea-orm-sync/src/tests_cfg/post_tag.rs +++ b/sea-orm-sync/src/tests_cfg/post_tag.rs @@ -10,9 +10,9 @@ pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub tag_id: i32, #[sea_orm(belongs_to, from = "post_id", to = "id")] - pub post: Option, + pub post: BelongsTo, #[sea_orm(belongs_to, from = "tag_id", to = "id")] - pub tag: Option, + pub tag: BelongsTo, } impl ActiveModelBehavior for ActiveModel {} diff --git a/sea-orm-sync/src/tests_cfg/profile.rs b/sea-orm-sync/src/tests_cfg/profile.rs index 1dbc9f1783..5d1c713068 100644 --- a/sea-orm-sync/src/tests_cfg/profile.rs +++ b/sea-orm-sync/src/tests_cfg/profile.rs @@ -11,7 +11,7 @@ pub struct Model { #[sea_orm(unique)] pub user_id: i32, #[sea_orm(belongs_to, from = "user_id", to = "id")] - pub user: HasOne, + pub user: BelongsTo, } impl ActiveModelBehavior for ActiveModel {} diff --git a/sea-orm-sync/tests/active_model_ex_tests.rs b/sea-orm-sync/tests/active_model_ex_tests.rs index 741284e05b..e69026b07a 100644 --- a/sea-orm-sync/tests/active_model_ex_tests.rs +++ b/sea-orm-sync/tests/active_model_ex_tests.rs @@ -6,6 +6,24 @@ use crate::common::TestContext; use sea_orm::{Database, DbConn, DbErr, entity::*, prelude::*, query::*}; use tracing::info; +mod optional_self_ref { + use sea_orm::entity::prelude::*; + + #[sea_orm::model] + #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] + #[sea_orm(table_name = "optional_self_ref")] + pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + #[sea_orm(enum_name = "ParentId")] + pub parent_ref: Option, + #[sea_orm(self_ref, relation_enum = "Parent", from = "ParentId", to = "id")] + pub parent: BelongsTo>, + } + + impl ActiveModelBehavior for ActiveModel {} +} + #[sea_orm_macros::test] fn test_active_model_ex_blog() -> Result<(), DbErr> { use common::blogger::*; @@ -44,7 +62,7 @@ fn test_active_model_ex_blog() -> Result<(), DbErr> { id: Unchanged(1), user_id: Unchanged(1), title: Unchanged("post 1".into()), - author: ActiveHasOne::set(user::ActiveModelEx { + author: ActiveBelongsTo::set(user::ActiveModelEx { id: Unchanged(1), name: Unchanged("Alice".into()), email: Unchanged("@1".into()), @@ -68,7 +86,7 @@ fn test_active_model_ex_blog() -> Result<(), DbErr> { if false { post::ActiveModelEx { title: Set("post 2".into()), - author: ActiveHasOne::set(user::ActiveModelEx { + author: ActiveBelongsTo::set(user::ActiveModelEx { name: Set("Bob".into()), email: Set("@2".into()), ..Default::default() @@ -83,7 +101,7 @@ fn test_active_model_ex_blog() -> Result<(), DbErr> { id: Unchanged(2), user_id: Unchanged(2), title: Unchanged("post 2".into()), - author: ActiveHasOne::set(user::ActiveModelEx { + author: ActiveBelongsTo::set(user::ActiveModelEx { id: Unchanged(2), name: Unchanged("Bob".into()), email: Unchanged("@2".into()), @@ -104,10 +122,10 @@ fn test_active_model_ex_blog() -> Result<(), DbErr> { user::ActiveModelEx { name: Set("Sam".into()), email: Set("@3".into()), - profile: ActiveHasOne::set(profile::ActiveModelEx { + profile: ActiveHasOne::set(Some(profile::ActiveModelEx { picture: Set("Sam.jpg".into()), ..Default::default() - }), + })), ..Default::default() }; } @@ -118,12 +136,12 @@ fn test_active_model_ex_blog() -> Result<(), DbErr> { id: Unchanged(3), name: Unchanged("Sam".into()), email: Unchanged("@3".into()), - profile: ActiveHasOne::set(profile::ActiveModelEx { + profile: ActiveHasOne::set(Some(profile::ActiveModelEx { id: Unchanged(1), picture: Unchanged("Sam.jpg".into()), user_id: Unchanged(3), - user: ActiveHasOne::NotSet, - }), + user: ActiveBelongsTo::NotSet, + })), ..Default::default() } ); @@ -143,12 +161,12 @@ fn test_active_model_ex_blog() -> Result<(), DbErr> { id: Unchanged(4), name: Unchanged("Alan".into()), email: Unchanged("@4".into()), - profile: ActiveHasOne::set(profile::ActiveModelEx { + profile: ActiveHasOne::set(Some(profile::ActiveModelEx { id: Unchanged(2), picture: Unchanged("Alan.jpg".into()), user_id: Unchanged(4), - user: ActiveHasOne::NotSet, - }), + user: ActiveBelongsTo::NotSet, + })), posts: ActiveHasMany::Append(vec![ post::ActiveModelEx { id: Unchanged(3), @@ -247,11 +265,11 @@ fn test_active_model_ex_blog() -> Result<(), DbErr> { id: 4, name: "Alan".into(), email: "@4".into(), - profile: HasOne::loaded(profile::Model { + profile: HasOne::loaded(Some(profile::Model { id: 2, picture: "Alan2.jpg".into(), user_id: 4, - }), + })), posts: HasMany::Loaded(vec![]), followers: HasMany::Unloaded, following: HasMany::Unloaded, @@ -275,7 +293,7 @@ fn test_active_model_ex_blog() -> Result<(), DbErr> { id: NotSet, user_id: NotSet, title: Set("post 7".into()), - author: ActiveHasOne::set(user.clone().into_active_model()), + author: ActiveBelongsTo::set(user.clone().into_active_model()), comments: ActiveHasMany::NotSet, attachments: ActiveHasMany::NotSet, tags: ActiveHasMany::Append(vec![ @@ -304,16 +322,16 @@ fn test_active_model_ex_blog() -> Result<(), DbErr> { id: Unchanged(7), user_id: Unchanged(4), title: Unchanged("post 7".into()), - author: ActiveHasOne::set(user::ActiveModelEx { + author: ActiveBelongsTo::set(user::ActiveModelEx { id: Unchanged(4), name: Unchanged("Alan".into()), email: Unchanged("@4".into()), - profile: ActiveHasOne::set(profile::ActiveModelEx { + profile: ActiveHasOne::set(Some(profile::ActiveModelEx { id: Unchanged(2), picture: Unchanged("Alan2.jpg".into()), user_id: Unchanged(4), - user: ActiveHasOne::NotSet, - }), + user: ActiveBelongsTo::NotSet, + },)), posts: ActiveHasMany::Append(vec![]), followers: ActiveHasMany::NotSet, following: ActiveHasMany::NotSet, @@ -478,16 +496,16 @@ fn test_active_model_ex_blog() -> Result<(), DbErr> { id: 5, name: "Bob".into(), email: "bob@sea-ql.org".into(), - profile: HasOne::loaded(profile::Model { + profile: HasOne::loaded(Some(profile::Model { id: 3, picture: "image.jpg".into(), user_id: 5, - }), + })), posts: HasMany::Loaded(vec![post::ModelEx { id: 8, user_id: 5, title: "Nice weather".into(), - author: HasOne::Unloaded, + author: BelongsTo::Unloaded, attachments: HasMany::Unloaded, comments: HasMany::Unloaded, tags: HasMany::Loaded(vec![tag::ModelEx { @@ -766,8 +784,8 @@ fn test_has_one_replace_and_delete() -> Result<(), DbErr> { assert_eq!(profiles.len(), 1); assert_eq!(profiles[0].picture, "second.jpg"); - info!("#3060: delete the HasOne via the generated delete_ builder"); - user.delete_profile().save(db)?; + info!("#3060: clear the HasOne via the generated clear_ builder"); + user.clear_profile().save(db)?; assert!(profile::Entity::find().all(db)?.is_empty()); @@ -777,73 +795,45 @@ fn test_has_one_replace_and_delete() -> Result<(), DbErr> { } #[sea_orm_macros::test] -fn test_belongs_to_nullable_detach() -> Result<(), DbErr> { - use common::bakery_dense::{bakery, cake}; - - let ctx = TestContext::new("test_belongs_to_nullable_detach"); - let db = &ctx.db; - - db.get_schema_builder() - .register(bakery::Entity) - .register(cake::Entity) - .apply(db)?; - - info!("attach a cake to a bakery through the nullable belongs_to"); - let cake = cake::ActiveModel::builder() - .set_name("Cheesecake") - .set_price(Decimal::from(10)) - .set_gluten_free(false) - .set_serial(Uuid::nil()) - .set_bakery( - bakery::ActiveModel::builder() - .set_name("SeaSide") - .set_profit_margin(10.0), - ) - .save(db)?; - assert!(cake::Entity::find().one(db)?.unwrap().bakery_id.is_some()); - - info!("delete_ on a nullable belongs_to nulls the FK and keeps both rows"); - cake.delete_bakery().save(db)?; - - let reloaded = cake::Entity::find().one(db)?.unwrap(); - assert!(reloaded.bakery_id.is_none()); - assert_eq!(bakery::Entity::find().all(db)?.len(), 1); - - ctx.delete(); - - Ok(()) -} - -#[sea_orm_macros::test] -fn test_belongs_to_non_null_detach_errors() -> Result<(), DbErr> { +fn test_belongs_to_duplicate_target() -> Result<(), DbErr> { use common::blogger::*; - let ctx = TestContext::new("test_belongs_to_non_null_detach_errors"); + let ctx = TestContext::new("test_belongs_to_duplicate_target"); let db = &ctx.db; db.get_schema_builder() .register(user::Entity) - .register(post::Entity) + .register(user_follower::Entity) .apply(db)?; - let user = user::ActiveModel::builder() + // `user_follower` has two belongs_to fields — `user` and `follower` — both + // targeting `user::Entity`. Nested writes on such duplicate-target relations + // used to be silently skipped; now each writes its own FK, disambiguated by + // relation (`follower` via its `relation_enum`, `user` via the default). + let alice = user::ActiveModel::builder() .set_name("Alice") .set_email("alice@sea-ql.org") .save(db)?; - let post = post::ActiveModel::builder() - .set_title("post 1") - .set_author(user) + let bob = user::ActiveModel::builder() + .set_name("Bob") + .set_email("bob@sea-ql.org") .save(db)?; - info!("detaching a non-nullable belongs_to (post.author) returns a clean error"); - let err = post.delete_author().save(db).unwrap_err(); - assert!( - matches!(err, DbErr::Type(_)), - "expected a clean DbErr::Type, got {err:?}" - ); + info!("link the two users through the disambiguated nested belongs_to"); + let follow = user_follower::ActiveModelEx { + user: ActiveBelongsTo::set(alice), + follower: ActiveBelongsTo::set(bob), + ..Default::default() + } + .insert(db)?; - // The row is untouched — nothing was deleted or nulled. - assert!(post::Entity::find().one(db)?.is_some()); + // Each belongs_to wrote its own FK (previously a silent no-op). + assert_eq!(follow.user_id, 1); + assert_eq!(follow.follower_id, 2); + + let row = user_follower::Entity::find().one(db)?.expect("row"); + assert_eq!(row.user_id, 1); + assert_eq!(row.follower_id, 2); ctx.delete(); @@ -851,10 +841,10 @@ fn test_belongs_to_non_null_detach_errors() -> Result<(), DbErr> { } #[sea_orm_macros::test] -fn test_detach_unset_belongs_to_is_noop() -> Result<(), DbErr> { +fn test_clear_belongs_to_clears_unset_fk() -> Result<(), DbErr> { use common::bakery_dense::{bakery, cake}; - let ctx = TestContext::new("test_detach_unset_belongs_to_is_noop"); + let ctx = TestContext::new("test_clear_belongs_to_clears_unset_fk"); let db = &ctx.db; db.get_schema_builder() @@ -862,17 +852,56 @@ fn test_detach_unset_belongs_to_is_noop() -> Result<(), DbErr> { .register(cake::Entity) .apply(db)?; - info!("delete_ on a never-set nullable belongs_to is a no-op, not an error"); - cake::ActiveModel::builder() + let bakery = bakery::ActiveModel::builder() + .set_name("Sea") + .set_profit_margin(0.0) + .insert(db)?; + let bakery_id = bakery.id; + let cake = cake::ActiveModel::builder() .set_name("Plain") .set_price(Decimal::from(5)) .set_gluten_free(true) .set_serial(Uuid::nil()) - .delete_bakery() - .save(db)?; + .set_bakery(bakery.clone()) + .insert(db)?; + let cake_with_option = cake::ActiveModel::builder() + .set_name("Option") + .set_price(Decimal::from(6)) + .set_gluten_free(true) + .set_serial(Uuid::nil()) + .set_bakery(bakery.clone()) + .insert(db)?; + + assert_eq!(cake.bakery_id, Some(bakery_id)); + assert_eq!(cake_with_option.bakery_id, Some(bakery_id)); + + let partial_cake = |id| cake::ActiveModelEx { + id: Unchanged(id), + name: NotSet, + price: NotSet, + bakery_id: NotSet, + gluten_free: NotSet, + serial: NotSet, + bakery: ActiveBelongsTo::NotSet, + lineitems: ActiveHasMany::NotSet, + bakers: ActiveHasMany::NotSet, + }; - let reloaded = cake::Entity::find().one(db)?.unwrap(); - assert!(reloaded.bakery_id.is_none()); + let cleared = partial_cake(cake.id).clear_bakery().update(db)?; + + assert!(cleared.bakery_id.is_none()); + let row = cake::Entity::find_by_id(cake.id).one(db)?.expect("cake"); + assert!(row.bakery_id.is_none()); + + let cleared = partial_cake(cake_with_option.id) + .set_bakery_option(None::) + .update(db)?; + + assert!(cleared.bakery_id.is_none()); + let row = cake::Entity::find_by_id(cake_with_option.id) + .one(db)? + .expect("cake"); + assert!(row.bakery_id.is_none()); ctx.delete(); @@ -880,45 +909,35 @@ fn test_detach_unset_belongs_to_is_noop() -> Result<(), DbErr> { } #[sea_orm_macros::test] -fn test_belongs_to_duplicate_target() -> Result<(), DbErr> { - use common::blogger::*; - - let ctx = TestContext::new("test_belongs_to_duplicate_target"); +fn test_clear_self_ref_belongs_to_clears_unset_fk() -> Result<(), DbErr> { + let ctx = TestContext::new("test_clear_self_ref_belongs_to_clears_unset_fk"); let db = &ctx.db; db.get_schema_builder() - .register(user::Entity) - .register(user_follower::Entity) + .register(optional_self_ref::Entity) .apply(db)?; - // `user_follower` has two belongs_to fields — `user` and `follower` — both - // targeting `user::Entity`. Nested writes on such duplicate-target relations - // used to be silently dropped; each now writes its own FK, disambiguated by - // relation (`follower` via its `relation_enum`, `user` via the default). - let alice = user::ActiveModel::builder() - .set_name("Alice") - .set_email("alice@sea-ql.org") - .save(db)?; - let bob = user::ActiveModel::builder() - .set_name("Bob") - .set_email("bob@sea-ql.org") - .save(db)?; + let parent = optional_self_ref::ActiveModel::builder().insert(db)?; + let parent_id = parent.id; + let child = optional_self_ref::ActiveModel::builder() + .set_parent(parent) + .insert(db)?; - info!("link the two users through the disambiguated nested belongs_to"); - let follow = user_follower::ActiveModelEx { - user: ActiveHasOne::set(alice), - follower: ActiveHasOne::set(bob), - ..Default::default() - } - .insert(db)?; + assert_eq!(child.parent_ref, Some(parent_id)); - // Each belongs_to wrote its own FK (previously a silent no-op). - assert_eq!(follow.user_id, 1); - assert_eq!(follow.follower_id, 2); + let cleared = optional_self_ref::ActiveModelEx { + id: Unchanged(child.id), + parent_ref: NotSet, + parent: ActiveBelongsTo::NotSet, + } + .clear_parent() + .update(db)?; - let row = user_follower::Entity::find().one(db)?.expect("row"); - assert_eq!(row.user_id, 1); - assert_eq!(row.follower_id, 2); + assert!(cleared.parent_ref.is_none()); + let row = optional_self_ref::Entity::find_by_id(child.id) + .one(db)? + .expect("child"); + assert!(row.parent_ref.is_none()); ctx.delete(); diff --git a/sea-orm-sync/tests/belongs_to_legacy_tests.rs b/sea-orm-sync/tests/belongs_to_legacy_tests.rs new file mode 100644 index 0000000000..4e63542217 --- /dev/null +++ b/sea-orm-sync/tests/belongs_to_legacy_tests.rs @@ -0,0 +1,169 @@ +#![allow(unused_imports, dead_code)] +//! Backward-compatibility ("legacy") suite: `belongs_to` relations declared with the +//! `HasOne` field type instead of the recommended `BelongsTo`. +//! +//! These mirror the primary blogger active-model / loader tests, but against fixtures whose +//! `belongs_to` fields keep the legacy `HasOne` type (`common::blogger_legacy`), locking in +//! that the legacy path keeps working: nested writes set the foreign key, duplicate-target +//! relations are disambiguated, many-to-many junctions persist, and the entity loader hydrates +//! the relations. + +mod common; + +use crate::common::TestContext; +use crate::common::blogger_legacy::*; +use sea_orm::{Database, DbConn, DbErr, entity::*, prelude::*, query::*}; +use tracing::info; + +fn setup(name: &str) -> TestContext { + let ctx = TestContext::new(name); + ctx.db + .get_schema_builder() + .register(user::Entity) + .register(user_follower::Entity) + .register(profile::Entity) + .register(post::Entity) + .register(post_tag::Entity) + .register(tag::Entity) + .register(attachment::Entity) + .register(comment::Entity) + .apply(&ctx.db) + .unwrap(); + ctx +} + +#[sea_orm_macros::test] +fn test_legacy_belongs_to_nested_write() -> Result<(), DbErr> { + let ctx = setup("test_legacy_belongs_to_nested_write"); + let db = &ctx.db; + + info!("save a user with a nested has_one profile"); + let alice = user::ActiveModel::builder() + .set_name("Alice") + .set_email("alice@sea-ql.org") + .set_profile(profile::ActiveModel::builder().set_picture("alice.jpg")) + .save(db)?; + assert_eq!(alice.id, Unchanged(1)); + + // the has_one child wrote its belongs_to foreign key back to this user + let profile_row = profile::Entity::find().one(db)?.expect("profile"); + assert_eq!(profile_row.user_id, 1); + + info!("save a post whose legacy belongs_to author is an existing user"); + let post = post::ActiveModel::builder() + .set_title("post 1") + .set_author(alice.clone()) + .save(db)?; + // the belongs_to (HasOne) write set this row's own foreign key + assert_eq!(post.user_id, alice.id); + + info!("save a post whose legacy belongs_to author is a brand-new nested user"); + let post2 = post::ActiveModel::builder() + .set_title("post 2") + .set_author( + user::ActiveModel::builder() + .set_name("Bob") + .set_email("bob@sea-ql.org"), + ) + .save(db)?; + assert_eq!(post2.user_id, Unchanged(2)); + assert_eq!(user::Entity::find().count(db)?, 2); + + ctx.delete(); + Ok(()) +} + +#[sea_orm_macros::test] +fn test_legacy_many_to_many() -> Result<(), DbErr> { + let ctx = setup("test_legacy_many_to_many"); + let db = &ctx.db; + + let author = user::ActiveModel::builder() + .set_name("Alice") + .set_email("alice@sea-ql.org") + .save(db)?; + + info!("insert a post with two tags through the post_tag junction (legacy HasOne belongs_to)"); + let post = post::ActiveModel::builder() + .set_title("A sunny day") + .set_author(author) + .add_tag(tag::ActiveModel::builder().set_tag("outdoor")) + .add_tag(tag::ActiveModel::builder().set_tag("weather")) + .save(db)?; + + let post_id = post.id.clone().unwrap(); + let loaded = post::Entity::load() + .filter_by_id(post_id) + .with(tag::Entity) + .one(db)? + .expect("post"); + let tags: Vec = loaded.tags.iter().map(|t| t.tag.clone()).collect(); + assert_eq!(tags, ["outdoor", "weather"]); + + ctx.delete(); + Ok(()) +} + +#[sea_orm_macros::test] +fn test_legacy_belongs_to_duplicate_target() -> Result<(), DbErr> { + let ctx = setup("test_legacy_belongs_to_duplicate_target"); + let db = &ctx.db; + + // `user_follower` has two belongs_to fields — `user` and `follower` — both targeting + // `user::Entity`, here declared with the legacy `HasOne` type. Each nested write must + // still set its own foreign key, disambiguated by relation (`follower` via its + // `relation_enum`, `user` via the default inferred variant). + let alice = user::ActiveModel::builder() + .set_name("Alice") + .set_email("alice@sea-ql.org") + .save(db)?; + let bob = user::ActiveModel::builder() + .set_name("Bob") + .set_email("bob@sea-ql.org") + .save(db)?; + + let follow = user_follower::ActiveModelEx { + user: ActiveHasOne::set(Some(alice)), + follower: ActiveHasOne::set(Some(bob)), + ..Default::default() + } + .insert(db)?; + + assert_eq!(follow.user_id, 1); + assert_eq!(follow.follower_id, 2); + + let row = user_follower::Entity::find().one(db)?.expect("row"); + assert_eq!((row.user_id, row.follower_id), (1, 2)); + + ctx.delete(); + Ok(()) +} + +#[sea_orm_macros::test] +fn test_legacy_entity_loader() -> Result<(), DbErr> { + let ctx = setup("test_legacy_entity_loader"); + let db = &ctx.db; + + user::ActiveModel::builder() + .set_name("Alice") + .set_email("alice@sea-ql.org") + .set_profile(profile::ActiveModel::builder().set_picture("alice.jpg")) + .add_post(post::ActiveModel::builder().set_title("post 1")) + .add_post(post::ActiveModel::builder().set_title("post 2")) + .save(db)?; + + info!("load the user together with its has_one profile and has_many posts"); + let user = user::Entity::load() + .filter_by_email("alice@sea-ql.org") + .with(profile::Entity) + .with(post::Entity) + .one(db)? + .expect("user"); + + assert_eq!(user.profile.as_ref().expect("profile").picture, "alice.jpg"); + let titles: Vec = user.posts.iter().map(|p| p.title.clone()).collect(); + assert_eq!(titles, ["post 1", "post 2"]); + + ctx.delete(); + Ok(()) +} diff --git a/sea-orm-sync/tests/common/bakery_dense/baker.rs b/sea-orm-sync/tests/common/bakery_dense/baker.rs index aeef45357d..08117d14d6 100644 --- a/sea-orm-sync/tests/common/bakery_dense/baker.rs +++ b/sea-orm-sync/tests/common/bakery_dense/baker.rs @@ -18,7 +18,7 @@ pub struct Model { on_update = "Cascade", on_delete = "SetNull" )] - pub bakery: HasOne, + pub bakery: BelongsTo>, #[sea_orm(has_many, via = "cakes_bakers::Baker")] pub cakes: HasMany, } diff --git a/sea-orm-sync/tests/common/bakery_dense/cake.rs b/sea-orm-sync/tests/common/bakery_dense/cake.rs index e0b0e3da71..12f248050b 100644 --- a/sea-orm-sync/tests/common/bakery_dense/cake.rs +++ b/sea-orm-sync/tests/common/bakery_dense/cake.rs @@ -16,7 +16,7 @@ pub struct Model { pub gluten_free: bool, pub serial: Uuid, #[sea_orm(belongs_to, from = "BakeryId", to = "Id")] - pub bakery: HasOne, + pub bakery: BelongsTo>, #[sea_orm(has_many)] pub lineitems: HasMany, #[sea_orm(has_many, via = "cakes_bakers")] diff --git a/sea-orm-sync/tests/common/bakery_dense/cakes_bakers.rs b/sea-orm-sync/tests/common/bakery_dense/cakes_bakers.rs index 105ba33581..2099795b7f 100644 --- a/sea-orm-sync/tests/common/bakery_dense/cakes_bakers.rs +++ b/sea-orm-sync/tests/common/bakery_dense/cakes_bakers.rs @@ -11,9 +11,9 @@ pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub baker_id: i32, #[sea_orm(belongs_to, from = "baker_id", to = "id")] - pub baker: Option, + pub baker: BelongsTo, #[sea_orm(belongs_to, from = "cake_id", to = "id")] - pub cake: Option, + pub cake: BelongsTo, } impl ActiveModelBehavior for ActiveModel {} diff --git a/sea-orm-sync/tests/common/bakery_dense/lineitem.rs b/sea-orm-sync/tests/common/bakery_dense/lineitem.rs index b893ea84d5..63fdb9a015 100644 --- a/sea-orm-sync/tests/common/bakery_dense/lineitem.rs +++ b/sea-orm-sync/tests/common/bakery_dense/lineitem.rs @@ -13,8 +13,8 @@ pub struct Model { pub quantity: i32, pub order_id: i32, pub cake_id: i32, - pub order: Option, - pub cake: Option, + pub order: BelongsTo, + pub cake: BelongsTo, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/sea-orm-sync/tests/common/bakery_dense/order.rs b/sea-orm-sync/tests/common/bakery_dense/order.rs index 5ddc9e3cdd..74da74cdf2 100644 --- a/sea-orm-sync/tests/common/bakery_dense/order.rs +++ b/sea-orm-sync/tests/common/bakery_dense/order.rs @@ -14,9 +14,9 @@ pub struct Model { pub customer_id: i32, pub placed_at: DateTimeWithTimeZone, #[sea_orm(belongs_to, from = "bakery_id", to = "id")] - pub bakery: HasOne, + pub bakery: BelongsTo, #[sea_orm(belongs_to, from = "customer_id", to = "id")] - pub customer: HasOne, + pub customer: BelongsTo, #[sea_orm(has_many)] pub lineitems: HasMany, } diff --git a/sea-orm-sync/tests/common/blogger/attachment.rs b/sea-orm-sync/tests/common/blogger/attachment.rs index 81c3508a77..2f821042bf 100644 --- a/sea-orm-sync/tests/common/blogger/attachment.rs +++ b/sea-orm-sync/tests/common/blogger/attachment.rs @@ -9,7 +9,7 @@ pub struct Model { pub post_id: Option, pub file: String, #[sea_orm(belongs_to, from = "post_id", to = "id")] - pub post: HasOne, + pub post: BelongsTo, } impl ActiveModelBehavior for ActiveModel {} diff --git a/sea-orm-sync/tests/common/blogger/comment.rs b/sea-orm-sync/tests/common/blogger/comment.rs index 8083a75a67..47f649e697 100644 --- a/sea-orm-sync/tests/common/blogger/comment.rs +++ b/sea-orm-sync/tests/common/blogger/comment.rs @@ -10,9 +10,9 @@ pub struct Model { pub user_id: i32, pub post_id: i32, #[sea_orm(belongs_to, from = "user_id", to = "id")] - pub user: HasOne, + pub user: BelongsTo, #[sea_orm(belongs_to, from = "post_id", to = "id")] - pub post: HasOne, + pub post: BelongsTo, } impl ActiveModelBehavior for ActiveModel {} diff --git a/sea-orm-sync/tests/common/blogger/post.rs b/sea-orm-sync/tests/common/blogger/post.rs index e827d639aa..e768150cf0 100644 --- a/sea-orm-sync/tests/common/blogger/post.rs +++ b/sea-orm-sync/tests/common/blogger/post.rs @@ -9,7 +9,7 @@ pub struct Model { pub user_id: i32, pub title: String, #[sea_orm(belongs_to, from = "user_id", to = "id")] - pub author: HasOne, + pub author: BelongsTo, #[sea_orm(has_many)] pub comments: HasMany, #[sea_orm(has_many)] diff --git a/sea-orm-sync/tests/common/blogger/post_tag.rs b/sea-orm-sync/tests/common/blogger/post_tag.rs index 6a50cb94ba..b80eccedf9 100644 --- a/sea-orm-sync/tests/common/blogger/post_tag.rs +++ b/sea-orm-sync/tests/common/blogger/post_tag.rs @@ -9,9 +9,9 @@ pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub tag_id: i32, #[sea_orm(belongs_to, from = "post_id", to = "id")] - pub post: Option, + pub post: BelongsTo, #[sea_orm(belongs_to, from = "tag_id", to = "id")] - pub tag: Option, + pub tag: BelongsTo, } impl ActiveModelBehavior for ActiveModel {} diff --git a/sea-orm-sync/tests/common/blogger/profile.rs b/sea-orm-sync/tests/common/blogger/profile.rs index 62173a777a..59d041bef3 100644 --- a/sea-orm-sync/tests/common/blogger/profile.rs +++ b/sea-orm-sync/tests/common/blogger/profile.rs @@ -10,7 +10,7 @@ pub struct Model { #[sea_orm(unique)] pub user_id: i32, #[sea_orm(belongs_to, from = "user_id", to = "id")] - pub user: HasOne, + pub user: BelongsTo, } impl ActiveModelBehavior for ActiveModel {} diff --git a/sea-orm-sync/tests/common/blogger/user_follower.rs b/sea-orm-sync/tests/common/blogger/user_follower.rs index bda8acaa1e..c0da970752 100644 --- a/sea-orm-sync/tests/common/blogger/user_follower.rs +++ b/sea-orm-sync/tests/common/blogger/user_follower.rs @@ -9,14 +9,14 @@ pub struct Model { #[sea_orm(primary_key)] pub follower_id: i32, #[sea_orm(belongs_to, from = "user_id", to = "id")] - pub user: Option, + pub user: BelongsTo, #[sea_orm( belongs_to, relation_enum = "Follower", from = "follower_id", to = "id" )] - pub follower: Option, + pub follower: BelongsTo, } impl ActiveModelBehavior for ActiveModel {} diff --git a/sea-orm-sync/tests/common/blogger_legacy/attachment.rs b/sea-orm-sync/tests/common/blogger_legacy/attachment.rs new file mode 100644 index 0000000000..81c3508a77 --- /dev/null +++ b/sea-orm-sync/tests/common/blogger_legacy/attachment.rs @@ -0,0 +1,15 @@ +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "attachment")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub post_id: Option, + pub file: String, + #[sea_orm(belongs_to, from = "post_id", to = "id")] + pub post: HasOne, +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/sea-orm-sync/tests/common/blogger_legacy/comment.rs b/sea-orm-sync/tests/common/blogger_legacy/comment.rs new file mode 100644 index 0000000000..8083a75a67 --- /dev/null +++ b/sea-orm-sync/tests/common/blogger_legacy/comment.rs @@ -0,0 +1,18 @@ +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "comment")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub comment: String, + pub user_id: i32, + pub post_id: i32, + #[sea_orm(belongs_to, from = "user_id", to = "id")] + pub user: HasOne, + #[sea_orm(belongs_to, from = "post_id", to = "id")] + pub post: HasOne, +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/sea-orm-sync/tests/common/blogger_legacy/mod.rs b/sea-orm-sync/tests/common/blogger_legacy/mod.rs new file mode 100644 index 0000000000..422697edec --- /dev/null +++ b/sea-orm-sync/tests/common/blogger_legacy/mod.rs @@ -0,0 +1,9 @@ +pub mod attachment; +pub mod comment; +pub mod post; +pub mod post_tag; +pub mod profile; +pub mod tag; +pub mod user; +pub mod user_follower; +pub mod user_mono; diff --git a/sea-orm-sync/tests/common/blogger_legacy/post.rs b/sea-orm-sync/tests/common/blogger_legacy/post.rs new file mode 100644 index 0000000000..e827d639aa --- /dev/null +++ b/sea-orm-sync/tests/common/blogger_legacy/post.rs @@ -0,0 +1,21 @@ +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "post")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub user_id: i32, + pub title: String, + #[sea_orm(belongs_to, from = "user_id", to = "id")] + pub author: HasOne, + #[sea_orm(has_many)] + pub comments: HasMany, + #[sea_orm(has_many)] + pub attachments: HasMany, + #[sea_orm(has_many, via = "post_tag")] + pub tags: HasMany, +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/sea-orm-sync/tests/common/blogger_legacy/post_tag.rs b/sea-orm-sync/tests/common/blogger_legacy/post_tag.rs new file mode 100644 index 0000000000..e62f5e8eb7 --- /dev/null +++ b/sea-orm-sync/tests/common/blogger_legacy/post_tag.rs @@ -0,0 +1,17 @@ +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[sea_orm(table_name = "post_tag")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub post_id: i32, + #[sea_orm(primary_key, auto_increment = false)] + pub tag_id: i32, + #[sea_orm(belongs_to, from = "post_id", to = "id")] + pub post: HasOne, + #[sea_orm(belongs_to, from = "tag_id", to = "id")] + pub tag: HasOne, +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/sea-orm-sync/tests/common/blogger_legacy/profile.rs b/sea-orm-sync/tests/common/blogger_legacy/profile.rs new file mode 100644 index 0000000000..62173a777a --- /dev/null +++ b/sea-orm-sync/tests/common/blogger_legacy/profile.rs @@ -0,0 +1,16 @@ +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "profile")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub picture: String, + #[sea_orm(unique)] + pub user_id: i32, + #[sea_orm(belongs_to, from = "user_id", to = "id")] + pub user: HasOne, +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/sea-orm-sync/tests/common/blogger_legacy/tag.rs b/sea-orm-sync/tests/common/blogger_legacy/tag.rs new file mode 100644 index 0000000000..3c82cdb1b2 --- /dev/null +++ b/sea-orm-sync/tests/common/blogger_legacy/tag.rs @@ -0,0 +1,14 @@ +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "tag")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub tag: String, + #[sea_orm(has_many, via = "post_tag")] + pub posts: HasMany, +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/sea-orm-sync/tests/common/blogger_legacy/user.rs b/sea-orm-sync/tests/common/blogger_legacy/user.rs new file mode 100644 index 0000000000..b165724c49 --- /dev/null +++ b/sea-orm-sync/tests/common/blogger_legacy/user.rs @@ -0,0 +1,36 @@ +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "user")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub name: String, + #[sea_orm(unique)] + pub email: String, + #[sea_orm(has_one)] + pub profile: HasOne, + #[sea_orm(has_many)] + pub posts: HasMany, + #[sea_orm(self_ref, via = "user_follower", from = "User", to = "Follower")] + pub followers: HasMany, + #[sea_orm(self_ref, via = "user_follower", reverse)] + pub following: HasMany, +} + +impl ActiveModelBehavior for ActiveModel {} + +pub use super::user_follower::Entity as Follower; +// the name `EntityReverse` is still subject to change +pub use super::user_follower::EntityReverse as Following; + +// the following trait impl will be generated by the marco: +// impl RelatedSelfVia for Entity { +// fn to() -> RelationDef { +// super::user_follower::Relation::Follower.def() +// } +// fn via() -> RelationDef { +// super::user_follower::Relation::User.def().rev() +// } +// } diff --git a/sea-orm-sync/tests/common/blogger_legacy/user_follower.rs b/sea-orm-sync/tests/common/blogger_legacy/user_follower.rs new file mode 100644 index 0000000000..3ac09d85c1 --- /dev/null +++ b/sea-orm-sync/tests/common/blogger_legacy/user_follower.rs @@ -0,0 +1,22 @@ +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[sea_orm(table_name = "user_follower")] +pub struct Model { + #[sea_orm(primary_key)] + pub user_id: i32, + #[sea_orm(primary_key)] + pub follower_id: i32, + #[sea_orm(belongs_to, from = "user_id", to = "id")] + pub user: HasOne, + #[sea_orm( + belongs_to, + relation_enum = "Follower", + from = "follower_id", + to = "id" + )] + pub follower: HasOne, +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/sea-orm-sync/tests/common/blogger_legacy/user_mono.rs b/sea-orm-sync/tests/common/blogger_legacy/user_mono.rs new file mode 100644 index 0000000000..604f99614f --- /dev/null +++ b/sea-orm-sync/tests/common/blogger_legacy/user_mono.rs @@ -0,0 +1,16 @@ +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "user")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub name: String, + #[sea_orm(unique)] + pub email: String, + #[sea_orm(self_ref, via = "user_follower", from = "User", to = "Follower")] + pub followers: HasMany, +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/sea-orm-sync/tests/common/features/self_join.rs b/sea-orm-sync/tests/common/features/self_join.rs index 2b458c3c88..4aabf15fa1 100644 --- a/sea-orm-sync/tests/common/features/self_join.rs +++ b/sea-orm-sync/tests/common/features/self_join.rs @@ -9,7 +9,7 @@ pub struct Model { pub uuid_ref: Option, pub time: Option