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