diff --git a/.gitignore b/.gitignore index 7ec785cd4a..435ce20d31 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,7 @@ Cargo.lock .vscode .idea/* */.idea/* +.zed/ +*/.zed/* .env.local -.DS_Store \ No newline at end of file +.DS_Store diff --git a/sea-orm-macros/src/derives/active_model.rs b/sea-orm-macros/src/derives/active_model.rs index 3171bd7954..96628cb3e7 100644 --- a/sea-orm-macros/src/derives/active_model.rs +++ b/sea-orm-macros/src/derives/active_model.rs @@ -91,6 +91,7 @@ impl DeriveActiveModel { fn impl_active_model(&self) -> TokenStream { let mut ts = self.impl_active_model_convert(); ts.extend(self.impl_active_model_trait()); + ts.extend(self.impl_insert_active_model_trait()); ts } @@ -124,6 +125,39 @@ impl DeriveActiveModel { ) } + fn impl_insert_active_model_trait(&self) -> TokenStream { + let fields = &self.fields; + let names = &self.names; + + quote! { + #[automatically_derived] + impl sea_orm::InsertActiveModelTrait for ActiveModel { + type Entity = Entity; + + fn is_to_safe_insert(&self) -> bool { + // Check each field, with short circuit + #( + { + let field_is_set = !matches!(self.#fields, sea_orm::ActiveValue::NotSet); + let col = ::Column::#names.def(); + let field_is_nullable = col.is_null(); + //FIXME: current default implementation doesn't seem to reflect the schema and seems to be a separate layer (I'm not sure). + // Also, this doesn't work with id columns + let field_has_default = col.get_column_default().is_some(); + + // TODO: Invert conditions + if !field_is_set && !field_is_nullable && !field_has_default { + return false; + } + } + )* + // Finnaly true + true + } + } + } + } + fn impl_active_model_trait(&self) -> TokenStream { let fields = &self.fields; let methods = self.impl_active_model_trait_methods(); diff --git a/src/entity/active_model.rs b/src/entity/active_model.rs index d7c6385a30..8f99db835e 100644 --- a/src/entity/active_model.rs +++ b/src/entity/active_model.rs @@ -12,6 +12,13 @@ use crate::{ use sea_query::ValueTuple; use std::fmt::Debug; +pub trait InsertActiveModelTrait: Clone + Debug { + type Entity: EntityTrait; + /// Checks if all required fields are set + /// Set, nullable or fields with defaults pass + fn is_to_safe_insert(&self) -> bool; +} + /// `ActiveModel` is a type for constructing `INSERT` and `UPDATE` statements for a particular table. /// /// Like [Model][ModelTrait], it represents a database record and each field represents a column. @@ -2241,6 +2248,193 @@ mod tests { Ok(()) } + #[test] + #[cfg(feature = "macros")] + fn test_is_to_safe_insert_all_set() { + // All fields explicitly Set -> always safe + mod my_entity { + use crate as sea_orm; + use crate::entity::prelude::*; + + #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] + #[sea_orm(table_name = "my_entity")] + pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub name: String, + pub description: Option, + } + + #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] + pub enum Relation {} + + impl ActiveModelBehavior for ActiveModel {} + } + + let am = my_entity::ActiveModel { + id: Set(1), + name: Set("hello".to_owned()), + description: Set(None), + }; + assert!(am.is_to_safe_insert()); + } + + #[test] + #[cfg(feature = "macros")] + fn test_is_to_safe_insert_required_field_not_set() { + // A non-nullable, non-defaulted field is NotSet -> not safe + mod my_entity { + use crate as sea_orm; + use crate::entity::prelude::*; + + #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] + #[sea_orm(table_name = "my_entity")] + pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub name: String, + } + + #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] + pub enum Relation {} + + impl ActiveModelBehavior for ActiveModel {} + } + + // `name` is required (non-nullable, no default) and not set + let am = my_entity::ActiveModel { + id: Set(1), + name: NotSet, + }; + assert!(!am.is_to_safe_insert()); + } + + #[test] + #[cfg(feature = "macros")] + fn test_is_to_safe_insert_nullable_field_not_set() { + // A nullable (Option) field that is NotSet is still safe, the DB will store NULL + mod my_entity { + use crate as sea_orm; + use crate::entity::prelude::*; + + #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] + #[sea_orm(table_name = "my_entity")] + pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub name: String, + pub description: Option, + } + + #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] + pub enum Relation {} + + impl ActiveModelBehavior for ActiveModel {} + } + + // `description` is nullable -> NotSet is fine + let am = my_entity::ActiveModel { + id: Set(1), + name: Set("hello".to_owned()), + description: NotSet, + }; + assert!(am.is_to_safe_insert()); + } + + #[test] + #[cfg(feature = "macros")] + fn test_is_to_safe_insert_auto_increment_pk_not_set() { + // The primary key has auto_increment=true -> NotSet is fine + mod my_entity { + use crate as sea_orm; + use crate::entity::prelude::*; + + #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] + #[sea_orm(table_name = "my_entity")] + pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub name: String, + } + + #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] + pub enum Relation {} + + impl ActiveModelBehavior for ActiveModel {} + } + + // `id` is auto-increment -> NotSet is acceptable + let am = my_entity::ActiveModel { + id: NotSet, + name: Set("hello".to_owned()), + }; + assert!(am.is_to_safe_insert()); + } + + #[test] + #[cfg(feature = "macros")] + fn test_is_to_safe_insert_field_with_default_not_set() { + // A non-nullable field that has a schema-level default -> NotSet is safe + mod my_entity { + use crate as sea_orm; + use crate::entity::prelude::*; + + #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] + #[sea_orm(table_name = "my_entity")] + pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub name: String, + #[sea_orm(default_value = 0)] + pub score: i32, + } + + #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] + pub enum Relation {} + + impl ActiveModelBehavior for ActiveModel {} + } + + // `score` has a default value -> NotSet is fine + let am = my_entity::ActiveModel { + id: Set(1), + name: Set("hello".to_owned()), + score: NotSet, + }; + assert!(am.is_to_safe_insert()); + } + + #[test] + #[cfg(feature = "macros")] + fn test_is_to_safe_insert_multiple_missing_required_fields() { + // Several required fields are NotSet -> not safe + mod my_entity { + use crate as sea_orm; + use crate::entity::prelude::*; + + #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] + #[sea_orm(table_name = "my_entity")] + pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub first_name: String, + pub last_name: String, + } + + #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] + pub enum Relation {} + + impl ActiveModelBehavior for ActiveModel {} + } + + let am = my_entity::ActiveModel { + id: Set(1), + first_name: NotSet, + last_name: NotSet, + }; + assert!(!am.is_to_safe_insert()); + } + #[smol_potat::test] #[cfg(feature = "with-json")] async fn test_active_model_set_from_json_3() -> Result<(), DbErr> {