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