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 {}