From db5f9a470401601c2b75667e3af02c14f8b30c51 Mon Sep 17 00:00:00 2001 From: Huliiiiii <134658521+Huliiiiii@users.noreply.github.com> Date: Tue, 9 Dec 2025 05:21:08 +0800 Subject: [PATCH] Support view --- sea-orm-codegen/src/entity/base_entity.rs | 6 + sea-orm-codegen/src/entity/transformer.rs | 1 + sea-orm-codegen/src/entity/writer.rs | 17 +++ sea-orm-macros/src/derives/attributes.rs | 1 + sea-orm-macros/src/derives/entity_model.rs | 118 +++++++++++++++---- sea-orm-macros/src/lib.rs | 31 ++++- src/entity/active_model.rs | 7 +- src/entity/mod.rs | 4 + src/entity/never_active_model.rs | 58 +++++++++ src/entity/never_primary_key.rs | 113 ++++++++++++++++++ tests/view_tests.rs | 131 +++++++++++++++++++++ 11 files changed, 457 insertions(+), 30 deletions(-) create mode 100644 src/entity/never_active_model.rs create mode 100644 src/entity/never_primary_key.rs create mode 100644 tests/view_tests.rs diff --git a/sea-orm-codegen/src/entity/base_entity.rs b/sea-orm-codegen/src/entity/base_entity.rs index 865130dc68..222678f58b 100644 --- a/sea-orm-codegen/src/entity/base_entity.rs +++ b/sea-orm-codegen/src/entity/base_entity.rs @@ -15,9 +15,14 @@ pub struct Entity { pub(crate) relations: Vec, pub(crate) conjunct_relations: Vec, pub(crate) primary_keys: Vec, + pub(crate) is_view: bool, } impl Entity { + pub fn is_view(&self) -> bool { + self.is_view + } + pub fn get_table_name_snake_case(&self) -> String { self.table_name.to_snake_case() } @@ -351,6 +356,7 @@ mod tests { primary_keys: vec![PrimaryKey { name: "id".to_owned(), }], + is_view: false, } } diff --git a/sea-orm-codegen/src/entity/transformer.rs b/sea-orm-codegen/src/entity/transformer.rs index ff8636ba44..64b3f60f2b 100644 --- a/sea-orm-codegen/src/entity/transformer.rs +++ b/sea-orm-codegen/src/entity/transformer.rs @@ -130,6 +130,7 @@ impl EntityTransformer { relations: relations.clone(), conjunct_relations: vec![], primary_keys, + is_view: false, }; entities.insert(table_name.clone(), entity.clone()); for mut rel in relations.into_iter() { diff --git a/sea-orm-codegen/src/entity/writer.rs b/sea-orm-codegen/src/entity/writer.rs index 57075e37e3..59f6071329 100644 --- a/sea-orm-codegen/src/entity/writer.rs +++ b/sea-orm-codegen/src/entity/writer.rs @@ -921,6 +921,7 @@ mod tests { via: "cake_filling".to_owned(), to: "filling".to_owned(), }], + is_view: false, primary_keys: vec![PrimaryKey { name: "id".to_owned(), }], @@ -970,6 +971,7 @@ mod tests { }, ], conjunct_relations: vec![], + is_view: false, primary_keys: vec![ PrimaryKey { name: "cake_id".to_owned(), @@ -1019,6 +1021,7 @@ mod tests { impl_related: true, }], conjunct_relations: vec![], + is_view: false, primary_keys: vec![ PrimaryKey { name: "cake_id".to_owned(), @@ -1053,6 +1056,7 @@ mod tests { via: "cake_filling".to_owned(), to: "cake".to_owned(), }], + is_view: false, primary_keys: vec![PrimaryKey { name: "id".to_owned(), }], @@ -1110,6 +1114,7 @@ mod tests { }, ], conjunct_relations: vec![], + is_view: false, primary_keys: vec![PrimaryKey { name: "id".to_owned(), }], @@ -1154,6 +1159,7 @@ mod tests { impl_related: true, }], conjunct_relations: vec![], + is_view: false, primary_keys: vec![PrimaryKey { name: "id".to_owned(), }], @@ -1324,6 +1330,7 @@ mod tests { }, ], conjunct_relations: vec![], + is_view: false, primary_keys: vec![PrimaryKey { name: "id".to_owned(), }], @@ -1371,6 +1378,7 @@ mod tests { via: "cake_filling".to_owned(), to: "filling".to_owned(), }], + is_view: false, primary_keys: vec![PrimaryKey { name: "id".to_owned(), }], @@ -1418,6 +1426,7 @@ mod tests { via: "cake_filling".to_owned(), to: "filling".to_owned(), }], + is_view: false, primary_keys: vec![PrimaryKey { name: "id".to_owned(), }], @@ -1452,6 +1461,7 @@ mod tests { ], relations: vec![], conjunct_relations: vec![], + is_view: false, primary_keys: vec![PrimaryKey { name: "id".to_owned(), }], @@ -1486,6 +1496,7 @@ mod tests { ], relations: vec![], conjunct_relations: vec![], + is_view: false, primary_keys: vec![PrimaryKey { name: "id".to_owned(), }], @@ -1522,6 +1533,7 @@ mod tests { impl_related: true, }], conjunct_relations: vec![], + is_view: false, primary_keys: vec![ PrimaryKey { name: "id1".to_owned(), @@ -1571,6 +1583,7 @@ mod tests { impl_related: true, }], conjunct_relations: vec![], + is_view: false, primary_keys: vec![PrimaryKey { name: "id".to_owned(), }], @@ -2155,6 +2168,7 @@ mod tests { via: "cake_filling".to_owned(), to: "filling".to_owned(), }], + is_view: false, primary_keys: vec![PrimaryKey { name: "id".to_owned(), }], @@ -2832,6 +2846,7 @@ mod tests { ], relations: vec![], conjunct_relations: vec![], + is_view: false, primary_keys: vec![PrimaryKey { name: "id".to_owned(), }], @@ -2941,6 +2956,7 @@ mod tests { ], relations: vec![], conjunct_relations: vec![], + is_view: false, primary_keys: vec![PrimaryKey { name: "id".to_owned(), }], @@ -3002,6 +3018,7 @@ mod tests { ], relations: vec![], conjunct_relations: vec![], + is_view: false, primary_keys: vec![PrimaryKey { name: "id".to_owned(), }], diff --git a/sea-orm-macros/src/derives/attributes.rs b/sea-orm-macros/src/derives/attributes.rs index 34a1506d0a..da19f2a76a 100644 --- a/sea-orm-macros/src/derives/attributes.rs +++ b/sea-orm-macros/src/derives/attributes.rs @@ -15,6 +15,7 @@ pub mod derive_attr { pub relation: Option, pub schema_name: Option, pub table_name: Option, + pub view: Option<()>, pub comment: Option, pub table_iden: Option<()>, pub rename_all: Option, diff --git a/sea-orm-macros/src/derives/entity_model.rs b/sea-orm-macros/src/derives/entity_model.rs index 8ffbd20aaf..c62498ded5 100644 --- a/sea-orm-macros/src/derives/entity_model.rs +++ b/sea-orm-macros/src/derives/entity_model.rs @@ -11,7 +11,9 @@ use syn::{ /// Method to derive an Model pub fn expand_derive_entity_model(data: &Data, attrs: &[Attribute]) -> syn::Result { // if #[sea_orm(table_name = "foo", schema_name = "bar")] specified, create Entity struct + // if #[sea_orm(table_name = "foo", view)] specified, create a View entity (read-only) let mut table_name = None; + let mut is_view = false; let mut comment = quote! {None}; let mut schema_name = quote! { None }; let mut table_iden = false; @@ -28,6 +30,8 @@ pub fn expand_derive_entity_model(data: &Data, attrs: &[Attribute]) -> syn::Resu comment = quote! { Some(#name) }; } else if meta.path.is_ident("table_name") { table_name = Some(meta.value()?.parse::()?); + } else if meta.path.is_ident("view") { + is_view = true; } else if meta.path.is_ident("schema_name") { let name: Lit = meta.value()?.parse()?; schema_name = quote! { Some(#name) }; @@ -48,32 +52,92 @@ pub fn expand_derive_entity_model(data: &Data, attrs: &[Attribute]) -> syn::Resu }) })?; - let entity_def = table_name + if is_view && table_name.is_none() { + return Err(syn::Error::new( + Span::call_site(), + "Attribute `view` requires `table_name` to be specified, e.g. #[sea_orm(table_name = \"foo\", view)].", + )); + } + + let entity_name = table_name.clone(); + + // TODO: Add registry support to views + + let entity_def = entity_name .as_ref() - .map(|table_name| { - let entity_extra_attr = if model_ex { - quote!(#[sea_orm(model_ex = ModelEx, active_model_ex = ActiveModelEx)]) - } else { - quote!() - }; - quote! { - #[doc = " Generated by sea-orm-macros"] - #[derive(Copy, Clone, Default, Debug, sea_orm::prelude::DeriveEntity)] - #entity_extra_attr - pub struct Entity; - - #[automatically_derived] - impl sea_orm::prelude::EntityName for Entity { - fn schema_name(&self) -> Option<&str> { - #schema_name + .map(|name| { + if is_view { + quote! { + #[doc = " Generated by sea-orm-macros"] + #[derive(Copy, Clone, Default, Debug)] + pub struct Entity; + + #[automatically_derived] + impl sea_orm::prelude::EntityName for Entity { + fn schema_name(&self) -> Option<&str> { + #schema_name + } + + fn table_name(&self) -> &'static str { + #name + } + + fn comment(&self) -> Option<&str> { + #comment + } + } + + #[automatically_derived] + impl sea_orm::prelude::Iden for Entity { + fn unquoted(&self) -> &str { + #name + } } - fn table_name(&self) -> &'static str { - #table_name + #[automatically_derived] + impl sea_orm::prelude::IdenStatic for Entity { + fn as_str(&self) -> &'static str { + #name + } + } + + #[automatically_derived] + impl sea_orm::prelude::EntityTrait for Entity { + type Model = Model; + type ModelEx = Model; + type ActiveModel = sea_orm::NeverActiveModel; + type ActiveModelEx = sea_orm::NeverActiveModel; + type Column = Column; + type PrimaryKey = PrimaryKey; + type Relation = Relation; } + } + } else { + // Generate regular Entity (implements EntityTrait) + let entity_extra_attr = if model_ex { + quote!(#[sea_orm(model_ex = ModelEx, active_model_ex = ActiveModelEx)]) + } else { + quote!() + }; + quote! { + #[doc = "Generated by sea-orm-macros"] + #[derive(Copy, Clone, Default, Debug, sea_orm::prelude::DeriveEntity)] + #entity_extra_attr + pub struct Entity; + + #[automatically_derived] + impl sea_orm::prelude::EntityName for Entity { + fn schema_name(&self) -> Option<&str> { + #schema_name + } - fn comment(&self) -> Option<&str> { - #comment + fn table_name(&self) -> &'static str { + #name + } + + fn comment(&self) -> Option<&str> { + #comment + } } } } @@ -90,11 +154,11 @@ pub fn expand_derive_entity_model(data: &Data, attrs: &[Attribute]) -> syn::Resu let mut primary_key_types: Punctuated<_, Comma> = Punctuated::new(); let mut auto_increment = true; if table_iden { - if let Some(table_name) = &table_name { + if let Some(name) = &entity_name { let table_field_name = Ident::new("Table", Span::call_site()); columns_enum.push(quote! { #[doc = " Generated by sea-orm-macros"] - #[sea_orm(table_name=#table_name)] + #[sea_orm(table_name=#name)] #[strum(disabled)] #table_field_name }); @@ -403,7 +467,13 @@ pub fn expand_derive_entity_model(data: &Data, attrs: &[Attribute]) -> syn::Resu columns_save_as.push_punct(Comma::default()); } - let primary_key = { + let primary_key = if is_view && primary_keys.is_empty() { + // Views may not have primary keys; use NeverPrimaryKey to satisfy EntityTrait bounds. + quote! { + #[doc = " Generated by sea-orm-macros"] + pub type PrimaryKey = sea_orm::NeverPrimaryKey; + } + } else { let auto_increment = auto_increment && primary_keys.len() == 1; let primary_key_types = if primary_key_types.len() == 1 { let first = primary_key_types.first(); diff --git a/sea-orm-macros/src/lib.rs b/sea-orm-macros/src/lib.rs index d6add47e8e..c522f3cb2d 100644 --- a/sea-orm-macros/src/lib.rs +++ b/sea-orm-macros/src/lib.rs @@ -146,21 +146,42 @@ pub fn derive_entity_model(input: TokenStream) -> TokenStream { panic!("Struct name must be Model"); } + let is_view = attrs.iter().any(|attr| { + if attr.path().is_ident("sea_orm") { + let mut found_view = false; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("view") { + found_view = true; + } else { + let _ = meta.value().and_then(|v| v.parse::()); + } + Ok(()) + }); + found_view + } else { + false + } + }); + let mut ts: TokenStream = derives::expand_derive_entity_model(&data, &attrs) .unwrap_or_else(Error::into_compile_error) .into(); + // Views are read-only: we still derive Model (ModelTrait + FromQueryResult), + // but skip deriving an ActiveModel / IntoActiveModel. ts.extend::( derives::expand_derive_model(&ident, &data, &attrs) .unwrap_or_else(Error::into_compile_error) .into(), ); - ts.extend::( - derives::expand_derive_active_model(&ident, &data) - .unwrap_or_else(Error::into_compile_error) - .into(), - ); + if !is_view { + ts.extend::( + derives::expand_derive_active_model(&ident, &data) + .unwrap_or_else(Error::into_compile_error) + .into(), + ); + } ts } diff --git a/src/entity/active_model.rs b/src/entity/active_model.rs index d8766be0cb..81c6685eb4 100644 --- a/src/entity/active_model.rs +++ b/src/entity/active_model.rs @@ -79,7 +79,12 @@ pub trait ActiveModelTrait: Clone + Debug { self.get(cols.next()?.into_column()).into_value()? }; } - match <<::PrimaryKey as PrimaryKeyTrait>::ValueType as PrimaryKeyArity>::ARITY { + let arity = + <<::PrimaryKey as PrimaryKeyTrait>::ValueType as PrimaryKeyArity>::ARITY; + if arity == 0 { + return None; + } + match arity { 1 => { let s1 = next!(); Some(ValueTuple::One(s1)) diff --git a/src/entity/mod.rs b/src/entity/mod.rs index d82fd4ce54..4925d41993 100644 --- a/src/entity/mod.rs +++ b/src/entity/mod.rs @@ -107,6 +107,8 @@ pub mod compound; mod identity; mod link; mod model; +mod never_active_model; +mod never_primary_key; mod partial_model; /// Re-export common types from the entity pub mod prelude; @@ -126,6 +128,8 @@ pub use compound::EntityLoaderTrait; pub use identity::*; pub use link::*; pub use model::*; +pub use never_active_model::*; +pub use never_primary_key::*; pub use partial_model::*; pub use primary_key::*; #[cfg(feature = "entity-registry")] diff --git a/src/entity/never_active_model.rs b/src/entity/never_active_model.rs new file mode 100644 index 0000000000..1424196798 --- /dev/null +++ b/src/entity/never_active_model.rs @@ -0,0 +1,58 @@ +use crate::{ActiveModelBehavior, ActiveModelTrait, ActiveValue, DbErr, EntityTrait, Value}; +use std::marker::PhantomData; + +/// A dummy ActiveModel for read-only entities (Views). +/// All write operations will return errors or panic at runtime. +#[derive(Clone, Debug)] +pub struct NeverActiveModel { + _marker: PhantomData, +} + +impl Default for NeverActiveModel { + fn default() -> Self { + Self { + _marker: PhantomData, + } + } +} + +impl ActiveModelTrait for NeverActiveModel { + type Entity = E; + + fn take(&mut self, _c: E::Column) -> ActiveValue { + panic!("Cannot modify a read-only view: attempted to take column value") + } + + fn get(&self, _c: E::Column) -> ActiveValue { + // Return NotSet for all columns - views don't track active values + ActiveValue::NotSet + } + + fn set_if_not_equals(&mut self, _c: E::Column, _v: Value) { + panic!("Cannot modify a read-only view: attempted to set column value") + } + + fn try_set(&mut self, _c: E::Column, _v: Value) -> Result<(), DbErr> { + Err(DbErr::Custom( + "Cannot modify a read-only view: attempted to set column value".to_owned(), + )) + } + + fn not_set(&mut self, _c: E::Column) {} + + fn is_not_set(&self, _c: E::Column) -> bool { + true + } + + fn default() -> Self { + ::default() + } + + fn default_values() -> Self { + ::default() + } + + fn reset(&mut self, _c: E::Column) {} +} + +impl ActiveModelBehavior for NeverActiveModel {} diff --git a/src/entity/never_primary_key.rs b/src/entity/never_primary_key.rs new file mode 100644 index 0000000000..e9953be201 --- /dev/null +++ b/src/entity/never_primary_key.rs @@ -0,0 +1,113 @@ +use crate::{ColumnTrait, DbErr, IdenStatic, PrimaryKeyArity, PrimaryKeyToColumn, PrimaryKeyTrait}; +use crate::{QueryResult, TryFromU64, TryGetError, TryGetableMany}; +use sea_query::{FromValueTuple, IntoValueTuple, ValueTuple}; +use std::marker::PhantomData; + +const NEVER_PRIMARY_KEY_IDEN: &str = "__never_primary_key__"; + +/// A dummy PrimaryKey type for entities without a primary key (e.g. database views). +/// +/// This is mainly intended for read-only usage; APIs that rely on primary keys +/// (e.g. `find_by_id`) will generally be unusable for such entities. +#[derive(Copy, Clone, Debug, Default)] +pub struct NeverPrimaryKey(PhantomData); + +impl sea_query::Iden for NeverPrimaryKey { + fn unquoted(&self) -> &str { + NEVER_PRIMARY_KEY_IDEN + } +} + +impl IdenStatic for NeverPrimaryKey +where + C: ColumnTrait, +{ + fn as_str(&self) -> &'static str { + NEVER_PRIMARY_KEY_IDEN + } +} + +impl strum::IntoEnumIterator for NeverPrimaryKey { + type Iterator = std::iter::Empty; + + fn iter() -> Self::Iterator { + std::iter::empty() + } +} + +impl PrimaryKeyToColumn for NeverPrimaryKey +where + C: ColumnTrait, +{ + type Column = C; + + fn into_column(self) -> Self::Column { + unreachable!("NeverPrimaryKey has no columns") + } + + fn from_column(_: Self::Column) -> Option + where + Self: Sized, + { + None + } +} + +impl PrimaryKeyTrait for NeverPrimaryKey +where + C: ColumnTrait, +{ + type ValueType = NeverPrimaryKeyValue; + + fn auto_increment() -> bool { + false + } +} + +/// The ValueType for [`NeverPrimaryKey`]. +/// +/// This type should never be constructed in normal code; it exists only to satisfy +/// trait bounds for entities without primary keys. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct NeverPrimaryKeyValue { + _priv: (), +} + +impl PrimaryKeyArity for NeverPrimaryKeyValue { + const ARITY: usize = 0; +} + +impl From for ValueTuple { + fn from(_: NeverPrimaryKeyValue) -> Self { + ValueTuple::Many(Vec::new()) + } +} + +impl FromValueTuple for NeverPrimaryKeyValue { + fn from_value_tuple(_: I) -> Self + where + I: IntoValueTuple, + { + Self { _priv: () } + } +} + +impl TryGetableMany for NeverPrimaryKeyValue { + fn try_get_many(_res: &QueryResult, _pre: &str, _cols: &[String]) -> Result { + Err(TryGetError::DbErr(DbErr::Custom( + "Entity has no primary key".to_owned(), + ))) + } + + fn try_get_many_by_index(_res: &QueryResult) -> Result { + Err(TryGetError::DbErr(DbErr::Custom( + "Entity has no primary key".to_owned(), + ))) + } +} + +impl TryFromU64 for NeverPrimaryKeyValue { + fn try_from_u64(_: u64) -> Result { + Err(DbErr::ConvertFromU64("NeverPrimaryKeyValue")) + } +} diff --git a/tests/view_tests.rs b/tests/view_tests.rs new file mode 100644 index 0000000000..54266a3281 --- /dev/null +++ b/tests/view_tests.rs @@ -0,0 +1,131 @@ +#![allow(unused)] +use sea_orm::{ConnectionTrait, DbBackend, EntityTrait, Statement, Value, query::QueryOrder}; + +use crate::common::TestContext; + +mod common; + +mod cake_view { + use sea_orm::entity::prelude::*; + + #[derive(Clone, Debug, PartialEq, DeriveEntityModel)] + #[sea_orm(table_name = "cake_view", view)] + pub struct Model { + pub id: i32, + pub name: String, + } + + #[derive(Copy, Clone, Debug, EnumIter)] + pub enum Relation {} + + impl RelationTrait for Relation { + fn def(&self) -> RelationDef { + panic!("Views are read-only and do not define relations") + } + } +} + +fn cake_table_ddl(backend: DbBackend) -> &'static str { + match backend { + DbBackend::Postgres => "CREATE TABLE cake (id SERIAL PRIMARY KEY, name VARCHAR NOT NULL);", + DbBackend::MySql => { + "CREATE TABLE cake (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL);" + } + DbBackend::Sqlite => { + "CREATE TABLE cake (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL);" + } + _ => unreachable!("unsupported backend for this integration test"), + } +} + +fn cake_view_ddl(backend: DbBackend) -> (&'static str, &'static str) { + match backend { + DbBackend::Postgres | DbBackend::MySql => ( + "DROP VIEW IF EXISTS cake_view;", + "CREATE VIEW cake_view AS SELECT id, name FROM cake;", + ), + DbBackend::Sqlite => ( + "DROP VIEW IF EXISTS cake_view;", + "CREATE VIEW cake_view AS SELECT id, name FROM cake;", + ), + _ => unreachable!("unsupported backend for this integration test"), + } +} + +#[sea_orm_macros::test] +async fn view_entity_can_query_but_cannot_modify() { + let ctx = TestContext::new("view_entity_can_query_but_cannot_modify").await; + let backend = ctx.db.get_database_backend(); + + ctx.db + .execute_raw(Statement::from_string( + backend, + "DROP VIEW IF EXISTS cake_view;".to_owned(), + )) + .await + .unwrap(); + ctx.db + .execute_raw(Statement::from_string( + backend, + "DROP TABLE IF EXISTS cake;".to_owned(), + )) + .await + .unwrap(); + ctx.db + .execute_raw(Statement::from_string( + backend, + cake_table_ddl(backend).to_owned(), + )) + .await + .unwrap(); + + ctx.db + .execute_raw(Statement::from_string( + backend, + "INSERT INTO cake (name) VALUES ('Cheesecake'), ('Chocolate');".to_owned(), + )) + .await + .unwrap(); + + let (drop_view, create_view) = cake_view_ddl(backend); + ctx.db + .execute_raw(Statement::from_string(backend, drop_view.to_owned())) + .await + .unwrap(); + ctx.db + .execute_raw(Statement::from_string(backend, create_view.to_owned())) + .await + .unwrap(); + + let rows = cake_view::Entity::find() + .order_by_asc(cake_view::Column::Id) + .all(&ctx.db) + .await + .unwrap(); + + assert_eq!( + rows, + vec![ + cake_view::Model { + id: 1, + name: "Cheesecake".to_owned() + }, + cake_view::Model { + id: 2, + name: "Chocolate".to_owned() + } + ] + ); + + type ViewActiveModel = ::ActiveModel; + let mut am = ViewActiveModel::default(); + let err = sea_orm::ActiveModelTrait::try_set( + &mut am, + cake_view::Column::Name, + Value::from("New Name"), + ) + .unwrap_err(); + assert!(matches!(err, sea_orm::DbErr::Custom(_))); + + ctx.delete().await; +}