Skip to main content

entity_core/
lib.rs

1// SPDX-FileCopyrightText: 2025-2026 RAprogramm <andrey.rozanov.vl@gmail.com>
2// SPDX-License-Identifier: MIT
3
4//! Core traits and types for entity-derive.
5//!
6//! This crate provides the foundational traits and types used by entity-derive
7//! generated code. It can also be used standalone for manual implementations.
8//!
9//! # Overview
10//!
11//! - [`Repository`] — Base trait for all generated repository traits
12//! - [`Pagination`] — Common pagination parameters
13//! - [`prelude`] — Convenient re-exports
14//!
15//! # Usage
16//!
17//! Most users should use `entity-derive` directly, which re-exports this crate.
18//! For manual implementations:
19//!
20//! ```rust,ignore
21//! use entity_core::prelude::*;
22//!
23//! #[async_trait]
24//! impl UserRepository for MyPool {
25//!     type Error = MyError;
26//!     type Pool = PgPool;
27//!     // ...
28//! }
29//! ```
30
31#![warn(missing_docs)]
32#![warn(clippy::all)]
33
34#[cfg(feature = "outbox")]
35pub mod outbox;
36pub mod policy;
37pub mod prelude;
38#[cfg(feature = "postgres")]
39pub mod schema;
40#[cfg(feature = "streams")]
41pub mod stream;
42pub mod transaction;
43
44/// Re-export `async_trait` for generated code.
45pub use async_trait::async_trait;
46
47/// Compare two strings in const context.
48///
49/// Used by generated code to verify at compile time that
50/// `#[column(pg_enum = "...")]` matches the `ValueObject`'s
51/// `#[value_object(pg_type = "...")]` declaration.
52#[must_use]
53pub const fn const_str_eq(a: &str, b: &str) -> bool {
54    let a = a.as_bytes();
55    let b = b.as_bytes();
56    if a.len() != b.len() {
57        return false;
58    }
59    let mut i = 0;
60    while i < a.len() {
61        if a[i] != b[i] {
62            return false;
63        }
64        i += 1;
65    }
66    true
67}
68
69/// Base repository trait.
70///
71/// All generated `{Entity}Repository` traits include these associated types
72/// and methods. This trait is not directly extended but serves as documentation
73/// for the common interface.
74///
75/// # Associated Types
76///
77/// - `Error` — Error type for repository operations
78/// - `Pool` — Underlying database pool type
79///
80/// # Example
81///
82/// Generated traits follow this pattern:
83///
84/// ```rust,ignore
85/// #[async_trait]
86/// pub trait UserRepository: Send + Sync {
87///     type Error: std::error::Error + Send + Sync;
88///     type Pool;
89///
90///     fn pool(&self) -> &Self::Pool;
91///     async fn create(&self, dto: CreateUserRequest) -> Result<User, Self::Error>;
92///     async fn find_by_id(&self, id: Uuid) -> Result<Option<User>, Self::Error>;
93///     // ...
94/// }
95/// ```
96pub trait Repository: Send + Sync {
97    /// Error type for repository operations.
98    ///
99    /// Must implement `std::error::Error + Send + Sync` for async
100    /// compatibility.
101    type Error: std::error::Error + Send + Sync;
102
103    /// Underlying database pool type.
104    ///
105    /// Enables access to the pool for transactions and custom queries.
106    type Pool;
107
108    /// Get reference to the underlying database pool.
109    ///
110    /// # Example
111    ///
112    /// ```rust,ignore
113    /// let pool = repo.pool();
114    /// let mut tx = pool.begin().await?;
115    /// // Custom operations...
116    /// tx.commit().await?;
117    /// ```
118    fn pool(&self) -> &Self::Pool;
119}
120
121/// Pagination parameters for list operations.
122///
123/// Used by `list` and `query` methods to control result pagination.
124///
125/// # Example
126///
127/// ```rust
128/// use entity_core::Pagination;
129///
130/// let page = Pagination::new(10, 0); // First 10 items
131/// let next = Pagination::new(10, 10); // Next 10 items
132/// ```
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub struct Pagination {
135    /// Maximum number of results to return.
136    pub limit: i64,
137
138    /// Number of results to skip.
139    pub offset: i64
140}
141
142impl Pagination {
143    /// Create new pagination parameters.
144    ///
145    /// # Arguments
146    ///
147    /// * `limit` — Maximum results to return
148    /// * `offset` — Number of results to skip
149    #[must_use]
150    pub const fn new(limit: i64, offset: i64) -> Self {
151        Self {
152            limit,
153            offset
154        }
155    }
156
157    /// Create pagination for a specific page.
158    ///
159    /// # Arguments
160    ///
161    /// * `page` — Page number (0-indexed)
162    /// * `per_page` — Items per page
163    ///
164    /// # Example
165    ///
166    /// ```rust
167    /// use entity_core::Pagination;
168    ///
169    /// let page_0 = Pagination::page(0, 25); // offset=0, limit=25
170    /// let page_2 = Pagination::page(2, 25); // offset=50, limit=25
171    /// ```
172    #[must_use]
173    pub const fn page(page: i64, per_page: i64) -> Self {
174        Self {
175            limit:  per_page,
176            offset: page * per_page
177        }
178    }
179}
180
181impl Default for Pagination {
182    fn default() -> Self {
183        Self {
184            limit:  100,
185            offset: 0
186        }
187    }
188}
189
190/// Sort direction for ordered queries.
191#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
192pub enum SortDirection {
193    /// Ascending order (A-Z, 0-9, oldest first).
194    #[default]
195    Asc,
196
197    /// Descending order (Z-A, 9-0, newest first).
198    Desc
199}
200
201impl SortDirection {
202    /// Convert to SQL keyword.
203    #[must_use]
204    pub const fn as_sql(&self) -> &'static str {
205        match self {
206            Self::Asc => "ASC",
207            Self::Desc => "DESC"
208        }
209    }
210}
211
212/// Kind of lifecycle event.
213///
214/// Used by generated event enums to categorize events.
215#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
216pub enum EventKind {
217    /// Entity was created.
218    Created,
219
220    /// Entity was updated.
221    Updated,
222
223    /// Entity was soft-deleted.
224    SoftDeleted,
225
226    /// Entity was hard-deleted (permanently removed).
227    HardDeleted,
228
229    /// Entity was restored from soft-delete.
230    Restored
231}
232
233impl EventKind {
234    /// Check if this is a delete event (soft or hard).
235    #[must_use]
236    pub const fn is_delete(&self) -> bool {
237        matches!(self, Self::SoftDeleted | Self::HardDeleted)
238    }
239
240    /// Check if this is a mutation event (create, update, delete).
241    #[must_use]
242    pub const fn is_mutation(&self) -> bool {
243        !matches!(self, Self::Restored)
244    }
245}
246
247/// Base trait for entity lifecycle events.
248///
249/// Generated event enums implement this trait, enabling generic
250/// event handling and dispatching.
251///
252/// # Example
253///
254/// ```rust,ignore
255/// fn handle_event<E: EntityEvent>(event: &E) {
256///     println!("Event {:?} for entity {:?}", event.kind(), event.entity_id());
257/// }
258/// ```
259pub trait EntityEvent: Send + Sync + std::fmt::Debug {
260    /// Type of entity ID.
261    type Id;
262
263    /// Get the kind of event.
264    fn kind(&self) -> EventKind;
265
266    /// Get the entity ID associated with this event.
267    fn entity_id(&self) -> &Self::Id;
268}
269
270/// Kind of business command.
271///
272/// Used by generated command enums to categorize commands for auditing
273/// and routing purposes.
274#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
275pub enum CommandKind {
276    /// Creates a new entity (e.g., Register, Create).
277    Create,
278
279    /// Modifies an existing entity (e.g., `UpdateEmail`, `ChangeStatus`).
280    Update,
281
282    /// Removes an entity (e.g., Delete, Deactivate).
283    Delete,
284
285    /// Custom business operation that doesn't fit CRUD.
286    Custom
287}
288
289impl CommandKind {
290    /// Check if this command creates an entity.
291    #[must_use]
292    pub const fn is_create(&self) -> bool {
293        matches!(self, Self::Create)
294    }
295
296    /// Check if this command modifies state.
297    #[must_use]
298    pub const fn is_mutation(&self) -> bool {
299        !matches!(self, Self::Custom)
300    }
301}
302
303/// Base trait for entity commands.
304///
305/// Generated command enums implement this trait, enabling generic
306/// command handling, auditing, and dispatching.
307///
308/// # Example
309///
310/// ```rust,ignore
311/// fn audit_command<C: EntityCommand>(cmd: &C) {
312///     log::info!("Executing command: {} ({:?})", cmd.name(), cmd.kind());
313/// }
314/// ```
315pub trait EntityCommand: Send + Sync + std::fmt::Debug {
316    /// Get the kind of command for categorization.
317    fn kind(&self) -> CommandKind;
318
319    /// Get the command name as a string for logging/auditing.
320    fn name(&self) -> &'static str;
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[test]
328    fn constraint_error_display_unique_field() {
329        let err = ConstraintError {
330            kind:       ConstraintKind::Unique,
331            constraint: "users_email_key".to_string(),
332            field:      Some("email")
333        };
334        assert_eq!(err.to_string(), "duplicate value for unique field `email`");
335    }
336
337    #[test]
338    fn constraint_error_display_fk_field() {
339        let err = ConstraintError {
340            kind:       ConstraintKind::ForeignKey,
341            constraint: "orders_user_id_fkey".to_string(),
342            field:      Some("user_id")
343        };
344        assert_eq!(
345            err.to_string(),
346            "referenced row missing for field `user_id`"
347        );
348    }
349
350    #[test]
351    fn constraint_error_display_unknown_field() {
352        let err = ConstraintError {
353            kind:       ConstraintKind::Check,
354            constraint: "orders_amount_check".to_string(),
355            field:      None
356        };
357        assert_eq!(
358            err.to_string(),
359            "Check constraint `orders_amount_check` violated"
360        );
361    }
362
363    #[test]
364    fn pagination_new() {
365        let p = Pagination::new(50, 100);
366        assert_eq!(p.limit, 50);
367        assert_eq!(p.offset, 100);
368    }
369
370    #[test]
371    fn pagination_page() {
372        let p = Pagination::page(2, 25);
373        assert_eq!(p.limit, 25);
374        assert_eq!(p.offset, 50);
375    }
376
377    #[test]
378    fn pagination_default() {
379        let p = Pagination::default();
380        assert_eq!(p.limit, 100);
381        assert_eq!(p.offset, 0);
382    }
383
384    #[test]
385    fn sort_direction_sql() {
386        assert_eq!(SortDirection::Asc.as_sql(), "ASC");
387        assert_eq!(SortDirection::Desc.as_sql(), "DESC");
388    }
389
390    #[test]
391    fn sort_direction_default() {
392        assert_eq!(SortDirection::default(), SortDirection::Asc);
393    }
394
395    #[test]
396    fn event_kind_is_delete() {
397        assert!(!EventKind::Created.is_delete());
398        assert!(!EventKind::Updated.is_delete());
399        assert!(EventKind::SoftDeleted.is_delete());
400        assert!(EventKind::HardDeleted.is_delete());
401        assert!(!EventKind::Restored.is_delete());
402    }
403
404    #[test]
405    fn event_kind_is_mutation() {
406        assert!(EventKind::Created.is_mutation());
407        assert!(EventKind::Updated.is_mutation());
408        assert!(EventKind::SoftDeleted.is_mutation());
409        assert!(EventKind::HardDeleted.is_mutation());
410        assert!(!EventKind::Restored.is_mutation());
411    }
412
413    #[test]
414    fn command_kind_is_create() {
415        assert!(CommandKind::Create.is_create());
416        assert!(!CommandKind::Update.is_create());
417        assert!(!CommandKind::Delete.is_create());
418        assert!(!CommandKind::Custom.is_create());
419    }
420
421    #[test]
422    fn command_kind_is_mutation() {
423        assert!(CommandKind::Create.is_mutation());
424        assert!(CommandKind::Update.is_mutation());
425        assert!(CommandKind::Delete.is_mutation());
426        assert!(!CommandKind::Custom.is_mutation());
427    }
428
429    #[test]
430    fn const_str_eq_equal_strings() {
431        assert!(const_str_eq("order_status", "order_status"));
432        assert!(const_str_eq("", ""));
433    }
434
435    #[test]
436    fn const_str_eq_different_lengths() {
437        assert!(!const_str_eq("order", "order_status"));
438        assert!(!const_str_eq("order_status", ""));
439    }
440
441    #[test]
442    fn const_str_eq_same_length_different_content() {
443        assert!(!const_str_eq("order_status", "order_states"));
444        assert!(!const_str_eq("abc", "abd"));
445    }
446
447    #[test]
448    fn const_str_eq_in_const_context() {
449        const OK: bool = const_str_eq("user_role", "user_role");
450        const MISMATCH: bool = const_str_eq("user_role", "user_rank");
451        assert_eq!((OK, MISMATCH), (true, false));
452    }
453}
454
455/// Serde helpers for generated DTOs.
456#[cfg(feature = "serde")]
457pub mod serde_helpers {
458    /// Double-`Option` (de)serialization for PATCH semantics.
459    ///
460    /// | JSON | Rust |
461    /// |------|------|
462    /// | field absent | `None` (leave unchanged) |
463    /// | `"field": null` | `Some(None)` (set column to NULL) |
464    /// | `"field": v` | `Some(Some(v))` (set column to v) |
465    pub mod double_option {
466        use serde::{Deserialize, Deserializer, Serialize, Serializer};
467
468        /// Deserialize a present-but-maybe-null field into `Some(inner)`.
469        ///
470        /// # Errors
471        ///
472        /// Propagates inner deserialization errors.
473        pub fn deserialize<'de, T, D>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
474        where
475            T: Deserialize<'de>,
476            D: Deserializer<'de>
477        {
478            Option::<T>::deserialize(deserializer).map(Some)
479        }
480
481        /// Serialize the inner `Option`, treating outer `None` as null.
482        ///
483        /// # Errors
484        ///
485        /// Propagates inner serialization errors.
486        pub fn serialize<T, S>(value: &Option<Option<T>>, serializer: S) -> Result<S::Ok, S::Error>
487        where
488            T: Serialize,
489            S: Serializer
490        {
491            match value {
492                Some(inner) => inner.serialize(serializer),
493                None => serializer.serialize_none()
494            }
495        }
496    }
497
498    #[cfg(all(test, feature = "serde_json"))]
499    mod tests {
500        #[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq, Default)]
501        struct Patch {
502            #[serde(
503                default,
504                skip_serializing_if = "Option::is_none",
505                with = "super::double_option"
506            )]
507            nick: Option<Option<String>>
508        }
509
510        #[test]
511        fn absent_field_is_outer_none() {
512            let patch: Patch = serde_json::from_str("{}").unwrap();
513            assert_eq!(patch.nick, None);
514        }
515
516        #[test]
517        fn null_field_is_some_none() {
518            let patch: Patch = serde_json::from_str(r#"{"nick": null}"#).unwrap();
519            assert_eq!(patch.nick, Some(None));
520        }
521
522        #[test]
523        fn value_field_is_some_some() {
524            let patch: Patch = serde_json::from_str(r#"{"nick": "neo"}"#).unwrap();
525            assert_eq!(patch.nick, Some(Some("neo".to_string())));
526        }
527
528        #[test]
529        fn outer_none_skipped_on_serialize() {
530            let json = serde_json::to_string(&Patch {
531                nick: None
532            })
533            .unwrap();
534            assert_eq!(json, "{}");
535        }
536
537        #[test]
538        fn some_none_serializes_null() {
539            let json = serde_json::to_string(&Patch {
540                nick: Some(None)
541            })
542            .unwrap();
543            assert_eq!(json, r#"{"nick":null}"#);
544        }
545    }
546}
547
548/// Kind of database constraint that was violated.
549#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
550pub enum ConstraintKind {
551    /// UNIQUE constraint or unique index.
552    Unique,
553
554    /// FOREIGN KEY constraint.
555    ForeignKey,
556
557    /// CHECK constraint.
558    Check
559}
560
561/// A database constraint violation resolved to entity metadata.
562///
563/// Produced by repositories generated with
564/// `#[entity(typed_constraints)]`: the generated code matches the
565/// violated constraint name against the set of constraints it created
566/// (unique columns, foreign keys, unique indexes) and hands callers a
567/// structured error instead of a raw driver error.
568///
569/// The repository `Error` type must implement
570/// `From<ConstraintError>` in addition to `From<sqlx::Error>`.
571#[derive(Debug, Clone, PartialEq, Eq)]
572pub struct ConstraintError {
573    /// What kind of constraint was violated.
574    pub kind: ConstraintKind,
575
576    /// Constraint name as reported by the database.
577    pub constraint: String,
578
579    /// Entity field the constraint maps to, when known.
580    pub field: Option<&'static str>
581}
582
583impl std::fmt::Display for ConstraintError {
584    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
585        match (self.kind, self.field) {
586            (ConstraintKind::Unique, Some(field)) => {
587                write!(f, "duplicate value for unique field `{field}`")
588            }
589            (ConstraintKind::ForeignKey, Some(field)) => {
590                write!(f, "referenced row missing for field `{field}`")
591            }
592            (kind, _) => write!(f, "{kind:?} constraint `{}` violated", self.constraint)
593        }
594    }
595}
596
597impl std::error::Error for ConstraintError {}
598
599/// A state-machine transition was attempted from a status it is not
600/// declared for.
601///
602/// Produced by repository methods generated from `#[transition(...)]`
603/// declarations. The consumer's error type must implement
604/// `From<TransitionError>`; map it to an HTTP 409 or a domain conflict.
605#[derive(Debug, Clone, PartialEq, Eq)]
606pub struct TransitionError {
607    /// Entity name the transition belongs to.
608    pub entity: &'static str,
609
610    /// Current status of the row, `Debug`-formatted.
611    pub from: String,
612
613    /// Target status of the attempted transition.
614    pub to: &'static str
615}
616
617impl std::fmt::Display for TransitionError {
618    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
619        write!(
620            f,
621            "{} cannot transition from `{}` to `{}`",
622            self.entity, self.from, self.to
623        )
624    }
625}
626
627impl std::error::Error for TransitionError {}