Skip to main content

adminx_core/
crud.rs

1// adminx-core/src/crud.rs
2//
3// The default create / update / delete, as free functions.
4//
5// Rust won't let an overriding impl call the trait's default method, so a
6// resource that needs to *extend* the default behaviour (reload a cache, send a
7// notification) historically had to copy the body — and any invariant later
8// added to the default, like audit recording, silently skipped those copies.
9//
10// Keeping the implementation here instead means `Resource`'s defaults and any
11// override delegate to the same code, so the invariants hold in both:
12//
13// ```ignore
14// async fn create(&self, ctx: &ReqCtx, body: Value) -> ApiResponse {
15//     let resp = adminx_core::crud::create(self, ctx, body).await;
16//     if resp.status < 300 {
17//         self.reload_cache().await;
18//     }
19//     resp
20// }
21// ```
22
23use crate::audit;
24use crate::authz::Action;
25use crate::error::CoreError;
26use crate::request::ReqCtx;
27use crate::resource::Resource;
28use crate::response::ApiResponse;
29use crate::storage::{storage, CreateOutcome};
30use serde_json::{json, Value};
31
32/// Authorize, filter to writable columns, insert, and record the create.
33pub async fn create<R: Resource + ?Sized>(res: &R, ctx: &ReqCtx, body: Value) -> ApiResponse {
34    if !res.authorize(ctx, Action::Create) {
35        return CoreError::Unauthorized.into();
36    }
37    let data = match res.filter_writable(body) {
38        Ok(d) => d,
39        Err(resp) => return resp,
40    };
41
42    // Only pay for the copy when something is listening.
43    let snapshot = audit::is_enabled().then(|| data.clone());
44    match storage().create(res.table_name(), data).await {
45        Ok(CreateOutcome { last_insert_id }) => {
46            if let Some(written) = snapshot {
47                let entry = audit::AuditEntry::new(
48                    ctx,
49                    res.base_path(),
50                    last_insert_id.clone().unwrap_or_default(),
51                    audit::Event::Create,
52                    audit::diff(None, &written),
53                );
54                if let Some(reject) = audit::emit(entry).await {
55                    return reject;
56                }
57            }
58            // Keep the search index in sync. Best-effort and gated on both a
59            // registered backend and the resource opting in, so an app without
60            // search pays nothing.
61            reindex_after_write(res, last_insert_id.as_deref()).await;
62
63            ApiResponse::created(json!({
64                "success": true,
65                "message": format!("{} created successfully", res.resource_name()),
66                "last_insert_id": last_insert_id,
67            }))
68        }
69        Err(e) => CoreError::from(e).into(),
70    }
71}
72
73/// After a create or update, index the record's current searchable document.
74/// Reads the row back so a partial update still indexes the full field set;
75/// issued only when search is on and the resource declares `search_fields`.
76async fn reindex_after_write<R: Resource + ?Sized>(res: &R, id: Option<&str>) {
77    let fields = res.search_fields();
78    if fields.is_empty() || !crate::search::is_enabled() {
79        return;
80    }
81    let Some(id) = id else { return };
82    if let Ok(Some(row)) = storage().get(res.table_name(), res.primary_key(), id).await {
83        let doc = crate::search::document_for(&row, &fields);
84        crate::search::index_record(res.base_path(), id, doc).await;
85    }
86}
87
88/// Authorize, filter to writable columns, update, and record the diff against
89/// the row it replaced.
90pub async fn update<R: Resource + ?Sized>(
91    res: &R,
92    ctx: &ReqCtx,
93    id: &str,
94    body: Value,
95) -> ApiResponse {
96    if !res.authorize(ctx, Action::Update) {
97        return CoreError::Unauthorized.into();
98    }
99    let data = match res.filter_writable(body) {
100        Ok(d) => d,
101        Err(resp) => return resp,
102    };
103
104    // The row as it stands, read only when auditing is on — this is the extra
105    // query, and an unaudited app never issues it. A read failure degrades the
106    // diff to "everything is new" rather than blocking the write.
107    let (before, snapshot) = if audit::is_enabled() {
108        let before = storage()
109            .get(res.table_name(), res.primary_key(), id)
110            .await
111            .unwrap_or(None);
112        (before, Some(data.clone()))
113    } else {
114        (None, None)
115    };
116
117    match storage()
118        .update(res.table_name(), res.primary_key(), id, data)
119        .await
120    {
121        Ok(n) if n > 0 => {
122            if let Some(written) = snapshot {
123                let changes = audit::diff(before.as_ref(), &written);
124                // A save that changed nothing is noise, not history.
125                if !changes.is_empty() {
126                    let entry = audit::AuditEntry::new(
127                        ctx,
128                        res.base_path(),
129                        id,
130                        audit::Event::Update,
131                        changes,
132                    );
133                    if let Some(reject) = audit::emit(entry).await {
134                        return reject;
135                    }
136                }
137            }
138            // Re-index the updated record (reads it back, so a partial update
139            // still indexes the full search document).
140            reindex_after_write(res, Some(id)).await;
141            ApiResponse::ok(json!({
142                "success": true,
143                "message": format!("{} updated successfully", res.resource_name()),
144                "modified_count": n,
145            }))
146        }
147        Ok(_) => CoreError::NotFound.into(),
148        Err(e) => CoreError::from(e).into(),
149    }
150}
151
152/// Authorize, delete (soft or hard per the resource), and record the record as
153/// it last existed.
154pub async fn delete<R: Resource + ?Sized>(res: &R, ctx: &ReqCtx, id: &str) -> ApiResponse {
155    if !res.authorize(ctx, Action::Delete) {
156        return CoreError::Unauthorized.into();
157    }
158    let soft = res.soft_delete();
159
160    // Capture the record before it goes: for a hard delete this is the only
161    // surviving copy, which is the whole point of auditing a destroy.
162    let before = if audit::is_enabled() {
163        storage()
164            .get(res.table_name(), res.primary_key(), id)
165            .await
166            .unwrap_or(None)
167    } else {
168        None
169    };
170
171    match storage()
172        .delete(res.table_name(), res.primary_key(), id, soft)
173        .await
174    {
175        Ok(n) if n > 0 => {
176            if audit::is_enabled() {
177                let changes = before.as_ref().map(audit::diff_removed).unwrap_or_default();
178                let entry =
179                    audit::AuditEntry::new(ctx, res.base_path(), id, audit::Event::Delete, changes);
180                if let Some(reject) = audit::emit(entry).await {
181                    return reject;
182                }
183            }
184            // Don't let blobs outlive the record they belonged to. Best-effort:
185            // a purge failure is logged, not surfaced — the delete already
186            // happened and blocking on cleanup would be worse than an orphan.
187            // Skipped on a soft delete, where the record still exists.
188            if !soft && crate::attach::is_enabled() {
189                crate::attach::purge(res.base_path(), id).await;
190            }
191            // Drop it from the search index too. A soft-deleted record still
192            // exists, but should no longer surface in search, so remove either way.
193            if crate::search::is_enabled() && !res.search_fields().is_empty() {
194                crate::search::remove_record(res.base_path(), id).await;
195            }
196            ApiResponse::ok(json!({
197                "success": true,
198                "message": format!("{} deleted successfully", res.resource_name()),
199                "soft_delete": soft,
200                "affected": n,
201            }))
202        }
203        Ok(_) => CoreError::NotFound.into(),
204        Err(e) => CoreError::from(e).into(),
205    }
206}