crudcrate 0.8.0

Derive complete REST APIs from Sea-ORM entities — endpoints, filtering, pagination, batch ops, and OpenAPI on Axum
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
use async_trait::async_trait;
use sea_orm::{
    Condition, DatabaseConnection, EntityTrait, IntoActiveModel, Order, PaginatorTrait, QueryOrder,
    QuerySelect, entity::prelude::*,
};
use uuid::Uuid;

use crate::ApiError;

/// Helper for extracting UUID PKs in batch queries.
/// Used by `delete_many` to verify which IDs actually existed.
#[derive(Debug, sea_orm::FromQueryResult)]
pub struct UuidIdResult {
    pub id: Uuid,
}

pub trait MergeIntoActiveModel<ActiveModelType> {
    /// Merge this update model into an existing active model
    ///
    /// # Errors
    ///
    /// Returns an `ApiError` if the merge operation fails due to data conversion issues.
    fn merge_into_activemodel(self, existing: ActiveModelType)
    -> Result<ActiveModelType, ApiError>;
}

#[async_trait]
pub trait CRUDResource: Sized + Send + Sync
where
    Self::EntityType: EntityTrait + Sync,
    Self::ActiveModelType: ActiveModelTrait + ActiveModelBehavior + Send + Sync,
    <Self::EntityType as EntityTrait>::Model: Sync + IntoActiveModel<Self::ActiveModelType>,
    <<Self::EntityType as EntityTrait>::PrimaryKey as PrimaryKeyTrait>::ValueType: From<Uuid>,
    <<Self::EntityType as EntityTrait>::PrimaryKey as PrimaryKeyTrait>::ValueType: Into<Uuid>,
    <<Self::EntityType as EntityTrait>::PrimaryKey as PrimaryKeyTrait>::ValueType: Into<Uuid>,
    Self: From<<Self::EntityType as EntityTrait>::Model>,
{
    type EntityType: EntityTrait + Sync;
    type ColumnType: ColumnTrait + std::fmt::Debug;
    type ActiveModelType: ActiveModelTrait<Entity = Self::EntityType>;
    type CreateModel: Into<Self::ActiveModelType> + Send;
    type UpdateModel: Send + Sync + MergeIntoActiveModel<Self::ActiveModelType>;
    type ListModel: From<Self> + Send + Sync;

    const ID_COLUMN: Self::ColumnType;
    const RESOURCE_NAME_SINGULAR: &str;
    const RESOURCE_NAME_PLURAL: &str;
    const TABLE_NAME: &'static str;
    const RESOURCE_DESCRIPTION: &'static str = "";
    const FULLTEXT_LANGUAGE: &'static str = "english";

    /// When true, read handlers return HTTP 500 if no `ScopeCondition` middleware is present.
    /// Set via `#[crudcrate(require_scope)]` on the struct.
    const REQUIRE_SCOPE: bool = false;

    /// Maximum number of items allowed in batch create/update/delete operations.
    /// Override with `#[crudcrate(batch_limit = 500)]` on your struct, or implement
    /// manually for runtime logic (env vars, config, etc.).
    #[must_use]
    fn batch_limit() -> usize {
        100
    }

    /// Maximum page size for pagination.
    /// Override with `#[crudcrate(max_page_size = 500)]` on your struct, or implement
    /// manually for runtime logic (env vars, config, etc.).
    #[must_use]
    fn max_page_size() -> u64 {
        1000
    }

    async fn get_all(
        db: &DatabaseConnection,
        condition: &Condition,
        order_column: Self::ColumnType,
        order_direction: Order,
        offset: u64,
        limit: u64,
    ) -> Result<Vec<Self::ListModel>, ApiError> {
        let models = Self::EntityType::find()
            .filter(condition.clone())
            .order_by(order_column, order_direction)
            .offset(offset)
            .limit(limit)
            .all(db)
            .await
            .map_err(ApiError::database)?;
        Ok(models
            .into_iter()
            .map(|model| Self::ListModel::from(Self::from(model)))
            .collect())
    }

    /// Scope-aware variant of `get_all` used by `get_all_handler` when a
    /// `ScopeCondition` extension is present. The parent-level scope is already
    /// merged into `condition` by the handler; this method is responsible for
    /// propagating scope into joined-child batch queries so private children
    /// are filtered at the SQL level.
    ///
    /// The derive macro overrides this to apply each child's
    /// `ScopeFilterable::scope_condition()` to the per-join batch query, and to
    /// recurse via `get_one_scoped` at depth > 1. The default impl delegates to
    /// `get_all`, which is safe for resources without `join(all)` children.
    async fn get_all_scoped(
        db: &DatabaseConnection,
        condition: &Condition,
        order_column: Self::ColumnType,
        order_direction: Order,
        offset: u64,
        limit: u64,
    ) -> Result<Vec<Self::ListModel>, ApiError> {
        Self::get_all(db, condition, order_column, order_direction, offset, limit).await
    }

    async fn get_one(db: &DatabaseConnection, id: Uuid) -> Result<Self, ApiError> {
        let model = Self::EntityType::find_by_id(id)
            .one(db)
            .await
            .map_err(ApiError::database)?
            .ok_or_else(|| {
                ApiError::not_found(Self::RESOURCE_NAME_SINGULAR, Some(id.to_string()))
            })?;
        Ok(Self::from(model))
    }

    /// Fetch a single entity by ID with a scope condition applied atomically.
    ///
    /// Unlike calling `get_one()` followed by a separate scope verification query,
    /// this combines the ID and scope condition into a single `WHERE id = ? AND <scope>`
    /// query, preventing TOCTOU races where the entity's scope-relevant columns could
    /// change between two separate queries.
    ///
    /// The derive macro overrides this to include join loading.
    async fn get_one_scoped(
        db: &DatabaseConnection,
        id: Uuid,
        scope: &Condition,
    ) -> Result<Self, ApiError> {
        use sea_orm::QueryFilter;
        let condition = Condition::all()
            .add(Self::ID_COLUMN.eq(id))
            .add(scope.clone());
        let model = Self::EntityType::find()
            .filter(condition)
            .one(db)
            .await
            .map_err(ApiError::database)?
            .ok_or_else(|| {
                ApiError::not_found(Self::RESOURCE_NAME_SINGULAR, Some(id.to_string()))
            })?;
        Ok(Self::from(model))
    }

    async fn create(
        db: &DatabaseConnection,
        create_model: Self::CreateModel,
    ) -> Result<Self, ApiError> {
        use sea_orm::ActiveModelTrait;
        let active_model: Self::ActiveModelType = create_model.into();

        // Use insert and return the model directly
        // This works across all databases unlike last_insert_id for UUIDs
        let model = active_model.insert(db).await.map_err(ApiError::database)?;

        // Convert the model to Self which implements CRUDResource
        // This gives us access to the id field directly
        Ok(Self::from(model))
    }

    async fn update(
        db: &DatabaseConnection,
        id: Uuid,
        update_model: Self::UpdateModel,
    ) -> Result<Self, ApiError> {
        let model = Self::EntityType::find_by_id(id)
            .one(db)
            .await
            .map_err(ApiError::database)?
            .ok_or_else(|| {
                ApiError::not_found(Self::RESOURCE_NAME_SINGULAR, Some(id.to_string()))
            })?;
        let existing: Self::ActiveModelType = model.into_active_model();
        let updated_model = update_model.merge_into_activemodel(existing)?;
        let updated = updated_model.update(db).await.map_err(ApiError::database)?;
        Ok(Self::from(updated))
    }

    async fn delete(db: &DatabaseConnection, id: Uuid) -> Result<Uuid, ApiError> {
        let res = Self::EntityType::delete_by_id(id)
            .exec(db)
            .await
            .map_err(ApiError::database)?;
        match res.rows_affected {
            0 => Err(ApiError::not_found(
                Self::RESOURCE_NAME_SINGULAR,
                Some(id.to_string()),
            )),
            _ => Ok(id),
        }
    }

    async fn delete_many(db: &DatabaseConnection, ids: Vec<Uuid>) -> Result<Vec<Uuid>, ApiError> {
        if ids.len() > Self::batch_limit() {
            return Err(ApiError::bad_request(format!(
                "Batch delete limited to {} items. Received {} items.",
                Self::batch_limit(),
                ids.len()
            )));
        }

        if ids.is_empty() {
            return Ok(vec![]);
        }

        // Pre-query: which IDs actually exist?
        let existing: Vec<UuidIdResult> = Self::EntityType::find()
            .select_only()
            .column_as(Self::ID_COLUMN, "id")
            .filter(Self::ID_COLUMN.is_in(ids.clone()))
            .into_model::<UuidIdResult>()
            .all(db)
            .await
            .map_err(ApiError::database)?;
        let existing_set: std::collections::HashSet<Uuid> =
            existing.into_iter().map(|r| r.id).collect();

        // Delete only existing IDs
        if !existing_set.is_empty() {
            Self::EntityType::delete_many()
                .filter(Self::ID_COLUMN.is_in(existing_set.iter().copied().collect::<Vec<_>>()))
                .exec(db)
                .await
                .map_err(ApiError::database)?;
        }

        // Return only IDs that actually existed (preserving input order)
        Ok(ids
            .into_iter()
            .filter(|id| existing_set.contains(id))
            .collect())
    }

    /// Create multiple entities in a batch.
    ///
    /// Uses a transaction to ensure all-or-nothing semantics: if any insert fails,
    /// the entire batch is rolled back and no entities are created.
    ///
    /// # Arguments
    /// * `db` - The database connection
    /// * `create_models` - A vector of create models to insert
    ///
    /// # Returns
    /// A vector of the created entities
    ///
    /// # Errors
    /// Returns an `ApiError` if any insert fails (entire batch is rolled back)
    async fn create_many(
        db: &DatabaseConnection,
        create_models: Vec<Self::CreateModel>,
    ) -> Result<Vec<Self>, ApiError> {
        use sea_orm::{ActiveModelTrait, TransactionTrait};

        // Security: Limit batch size to prevent DoS attacks
        if create_models.len() > Self::batch_limit() {
            return Err(ApiError::bad_request(format!(
                "Batch create limited to {} items. Received {} items.",
                Self::batch_limit(),
                create_models.len()
            )));
        }

        // Use a transaction for all-or-nothing semantics
        let txn = db.begin().await.map_err(ApiError::database)?;

        let mut results = Vec::with_capacity(create_models.len());
        for create_model in create_models {
            let active_model: Self::ActiveModelType = create_model.into();
            let model = match active_model.insert(&txn).await {
                Ok(m) => m,
                Err(e) => {
                    // Rollback is automatic when txn is dropped
                    return Err(ApiError::database(e));
                }
            };
            results.push(Self::from(model));
        }

        txn.commit().await.map_err(ApiError::database)?;
        Ok(results)
    }

    /// Update multiple entities in a batch.
    ///
    /// Uses a transaction to ensure all-or-nothing semantics: if any update fails,
    /// the entire batch is rolled back and no entities are updated.
    ///
    /// # Arguments
    /// * `db` - The database connection
    /// * `updates` - A vector of (id, `update_model`) pairs
    ///
    /// # Returns
    /// A vector of the updated entities
    ///
    /// # Errors
    /// Returns an `ApiError` if any update fails (entire batch is rolled back)
    async fn update_many(
        db: &DatabaseConnection,
        updates: Vec<(Uuid, Self::UpdateModel)>,
    ) -> Result<Vec<Self>, ApiError> {
        use sea_orm::TransactionTrait;

        // Security: Limit batch size to prevent DoS attacks
        if updates.len() > Self::batch_limit() {
            return Err(ApiError::bad_request(format!(
                "Batch update limited to {} items. Received {} items.",
                Self::batch_limit(),
                updates.len()
            )));
        }

        // Use a transaction for atomicity
        let txn = db.begin().await.map_err(ApiError::database)?;

        let mut results = Vec::with_capacity(updates.len());
        for (id, update_model) in updates {
            let model = Self::EntityType::find_by_id(id)
                .one(&txn)
                .await
                .map_err(ApiError::database)?
                .ok_or_else(|| {
                    ApiError::not_found(Self::RESOURCE_NAME_SINGULAR, Some(id.to_string()))
                })?;
            let existing: Self::ActiveModelType = model.into_active_model();
            let updated_model = update_model.merge_into_activemodel(existing)?;
            let updated = updated_model
                .update(&txn)
                .await
                .map_err(ApiError::database)?;
            results.push(Self::from(updated));
        }

        txn.commit().await.map_err(ApiError::database)?;
        Ok(results)
    }

    async fn total_count(db: &DatabaseConnection, condition: &Condition) -> u64 {
        let query = Self::EntityType::find().filter(condition.clone());
        match PaginatorTrait::count(query, db).await {
            Ok(count) => count,
            Err(e) => {
                // Log database error internally; return 0 to degrade gracefully
                // Users see pagination with count=0, internal error is logged for debugging
                tracing::warn!(
                    error = %e,
                    table = Self::TABLE_NAME,
                    "Database error in total_count - returning 0"
                );
                0
            }
        }
    }

    #[must_use]
    fn default_index_column() -> Self::ColumnType {
        Self::ID_COLUMN
    }

    #[must_use]
    fn sortable_columns() -> Vec<(&'static str, Self::ColumnType)> {
        vec![("id", Self::ID_COLUMN)]
    }

    #[must_use]
    fn filterable_columns() -> Vec<(&'static str, Self::ColumnType)> {
        vec![("id", Self::ID_COLUMN)]
    }

    /// Check if a specific field is an enum type at runtime.
    /// This is used to determine which fields need special enum handling.
    /// Default implementation returns false.
    #[must_use]
    fn is_enum_field(field_name: &str) -> bool {
        let _ = field_name;
        false
    }

    /// Normalizes an enum value for case-insensitive matching.
    /// This is used for enum types that don't support case-insensitive operations.
    /// Default implementation returns None, indicating no enum normalization is available.
    /// Override this method to provide enum value mapping for specific fields.
    #[must_use]
    fn normalize_enum_value(_field_name: &str, _value: &str) -> Option<String> {
        None
    }

    /// Returns a list of field names that should use LIKE queries (substring matching).
    /// Other string fields will use exact matching.
    /// Default is empty - no fields use LIKE by default.
    #[must_use]
    fn like_filterable_columns() -> Vec<&'static str> {
        vec![]
    }

    /// Returns a list of field names and their column types that should be included in fulltext search.
    /// These fields will be concatenated and searched when the 'q' parameter is used.
    /// Default is empty - no fields are included in fulltext search by default.
    #[must_use]
    fn fulltext_searchable_columns() -> Vec<(&'static str, Self::ColumnType)> {
        vec![]
    }

    /// Returns column names excluded from filtering/sorting when a `ScopeCondition` is active.
    ///
    /// Fields marked with `#[crudcrate(exclude(scoped))]` are automatically included.
    /// When a request is scoped (e.g. public/unauthenticated), these columns are stripped
    /// from the filterable and sortable lists to prevent schema probing.
    ///
    /// Default: empty (no columns excluded).
    #[must_use]
    fn scoped_excluded_columns() -> &'static [&'static str] {
        &[]
    }

    /// Returns a list of filterable columns on joined/related entities.
    ///
    /// These columns can be filtered using dot-notation in query parameters:
    /// ```ignore
    /// GET /customers?filter={"vehicles.make":"BMW","vehicles.year_gte":2020}
    /// ```
    ///
    /// Define on join fields using:
    /// ```ignore
    /// #[crudcrate(join(one, all, filterable("make", "year", "color")))]
    /// pub vehicles: Vec<Vehicle>,
    /// ```
    #[must_use]
    fn joined_filterable_columns() -> Vec<crate::JoinedColumnDef> {
        vec![]
    }

    /// Returns a list of sortable columns on joined/related entities.
    ///
    /// These columns can be sorted using dot-notation in query parameters:
    /// ```ignore
    /// GET /customers?sort=["vehicles.year","DESC"]
    /// ```
    ///
    /// Define on join fields using:
    /// ```ignore
    /// #[crudcrate(join(one, all, sortable("year", "mileage")))]
    /// pub vehicles: Vec<Vehicle>,
    /// ```
    #[must_use]
    fn joined_sortable_columns() -> Vec<crate::JoinedColumnDef> {
        vec![]
    }
}