Skip to main content

backbone_orm/
generic_repository.rs

1//! `GenericCrudRepository<T, DeleteMode>` — one implementation for all entities.
2//!
3//! The generated repository file previously duplicated ~20 identical method bodies
4//! for every entity.  This module provides those implementations once so that
5//! the generator can emit a thin newtype wrapper instead of hundreds of lines of
6//! copy-paste.
7//!
8//! # Delete mode markers
9//!
10//! | Marker | Behaviour |
11//! |--------|-----------|
12//! | [`SoftDelete`] | `deleted_at` stored in `metadata` JSONB; active rows filter `IS NULL` |
13//! | [`HardDelete`] | Rows are permanently deleted; no trash concept |
14//!
15//! # Generated output (after this change)
16//!
17//! ```rust,ignore
18//! // ~20 lines total instead of ~460-560
19//! pub struct UserRepository(
20//!     backbone_orm::GenericCrudRepository<User, backbone_orm::SoftDelete>
21//! );
22//! impl std::ops::Deref for UserRepository {
23//!     type Target = backbone_orm::GenericCrudRepository<User, backbone_orm::SoftDelete>;
24//!     fn deref(&self) -> &Self::Target { &self.0 }
25//! }
26//! impl UserRepository {
27//!     pub fn new(pool: sqlx::PgPool) -> Self {
28//!         Self(backbone_orm::GenericCrudRepository::new(pool, "users"))
29//!     }
30//!     // entity-specific methods only:
31//!     pub async fn find_by_email(&self, email: &str) -> anyhow::Result<Option<User>> { … }
32//!     pub async fn partial_update(…) { … }
33//!     pub async fn list_paginated_filtered(…) { … }
34//! }
35//! backbone_core::impl_crud_repository!(UserRepository, User, soft_delete);
36//! ```
37
38use std::collections::HashMap;
39use std::marker::PhantomData;
40
41use anyhow::Result;
42use serde::Serialize;
43use sqlx::postgres::PgRow;
44use sqlx::{FromRow, PgPool};
45use uuid::Uuid;
46
47use crate::repository::{
48    DatabaseOperations, PaginatedResult, PaginationInfo, PaginationParams, PostgresRepository,
49};
50
51// ─── EntityRepoMeta trait ─────────────────────────────────────────────────────
52
53/// Metadata trait that entities implement so `GenericCrudRepository` can provide
54/// `list_paginated_filtered` and `list_deleted_filtered` without per-entity boilerplate.
55///
56/// Generated once per entity by the `rust` generator.
57///
58/// | Method | Purpose |
59/// |--------|---------|
60/// | `column_types` | PostgreSQL cast hints (e.g. `{"id": "uuid"}`) |
61/// | `search_fields` | Text columns searched with `ILIKE` |
62pub trait EntityRepoMeta {
63    /// PostgreSQL type hints for filter/sort (e.g. `{"id": "uuid"}`).
64    fn column_types() -> HashMap<String, String>;
65    /// Text columns to include in full-text / ILIKE search.
66    fn search_fields() -> &'static [&'static str];
67
68    // ── Field-level security (response shaping) ──
69    // Names below are RESPONSE JSON keys (camelCase), not DB columns — they are
70    // matched against the serialized response object. Defaults = no restriction.
71
72    /// Response fields visible only to the row owner and platform/root callers
73    /// (`@private`). Stripped for everyone else.
74    fn private_fields() -> &'static [&'static str] {
75        &[]
76    }
77
78    /// Response fields (camelCase) that never leave the server (`@sensitive`): no
79    /// caller is served them, a platform caller or the row's owner included.
80    fn secret_fields() -> &'static [&'static str] {
81        &[]
82    }
83
84    /// The `@sensitive` fields (camelCase response keys) of the model a to-one
85    /// relation points at, stripped from the related row an `?include=` expands.
86    /// Keyed by the relation name of [`relations`](Self::relations). Default: none.
87    fn relation_secret_fields(_relation: &str) -> &'static [&'static str] {
88        &[]
89    }
90
91    /// Fields (camelCase response keys) a caller may see and set only while holding a
92    /// permission, as `(field, permission)`. The serialization seam redacts the value to `null`
93    /// (the key stays, so response shapes are stable) for a caller whose resolved grants do not
94    /// hold the permission, list-shaped requests may not filter, sort, search or aggregate over
95    /// one without it, and a write that sets one is refused naming the permission. `*:*` (and
96    /// `field_resource:*` / `*:action` wildcards) hold everything, as any other permission.
97    /// Default: no field is gated. The schema generator fills it from `requires:` on fields.
98    fn field_permissions() -> &'static [(&'static str, &'static str)] {
99        &[]
100    }
101
102    /// The response field (camelCase) holding the owner/company id (`@owner`),
103    /// compared against the caller's access scope to decide private-field
104    /// visibility. `None` = the entity has no owner concept.
105    fn owner_field() -> Option<&'static str> {
106        None
107    }
108
109    // ── Row-level company isolation ──
110
111    /// The DB column that scopes rows to a company (snake_case, e.g. `company_id`).
112    /// `None` = the entity is global (reference data) and is not fenced.
113    ///
114    /// The generator emits this **structurally** — from the presence of the company
115    /// column, a fact it cannot forget — and an entity opts OUT with `@global`.
116    /// The polarity is deliberate: `owner_field()` above is opt-IN via `@owner`, and
117    /// in a year of generated entities not one model ever declared it, so the whole
118    /// field-security path silently never ran. Security that must be remembered, in a
119    /// file nobody edits for security reasons, defaults to open and stays open. Here
120    /// silence means fenced, and forgetting is the safe direction.
121    ///
122    /// This is a *row-visibility fence*, the same category as `SoftDelete`'s
123    /// unconditional `deleted_at IS NULL` — not domain logic.
124    fn company_field() -> Option<&'static str> {
125        None
126    }
127
128    /// To-one relations expandable via `?include=<name>`, as
129    /// `(relation_name, target_table, local_fk_field)` — all response keys
130    /// (relation_name/local_fk in camelCase; target_table is the DB table).
131    /// Default: none.
132    fn relations() -> &'static [(&'static str, &'static str, &'static str)] {
133        &[]
134    }
135}
136
137// ─── Delete-mode markers ──────────────────────────────────────────────────────
138
139// ─── Row-level company fence ───────────────────────────────────────────────────
140
141/// The caller could not be scoped to a company for an entity that requires one.
142///
143/// Deliberately an error and not an empty result: an empty list is a silent lie that
144/// looks identical to "no data" and gets debugged for days, whereas this names the
145/// wiring bug. Callers should surface it as 403.
146#[derive(Debug, thiserror::Error)]
147#[error("entity is company-scoped ({column}) but the request carries no company scope")]
148pub struct MissingCompanyScope {
149    /// The column that would have fenced the query.
150    pub column: &'static str,
151}
152
153/// Build the SQL fence for `T`, or fail closed.
154///
155/// - entity is global (`company_field() == None`) → no fence, reference data reads freely
156/// - entity is fenced but the request has no company → **`Err`**, never an unfenced query
157/// - both present → `col = '<uuid>'`, ANDed into the WHERE by the caller
158///
159/// The literal is safe to interpolate: `column` is generator-emitted (never client
160/// input) and `Uuid`'s `Display` can only produce hex and dashes, so neither side can
161/// carry a quote. A base condition is code-authored SQL with no parameter slot, the
162/// same seam the soft-delete guard uses.
163pub fn company_fence<T: EntityRepoMeta>(company: Option<Uuid>) -> Result<Option<String>, MissingCompanyScope> {
164    match (T::company_field(), company) {
165        (None, _) => Ok(None),
166        (Some(column), None) => Err(MissingCompanyScope { column }),
167        (Some(column), Some(id)) => Ok(Some(format!("{column} = '{id}'"))),
168    }
169}
170
171/// Remove any caller-supplied filter on the company column.
172///
173/// The HTTP filter map is `#[serde(flatten)]`, so `?company_id=<victim>` arrives as an
174/// ordinary filter and the builder honours it. The fence is ANDed, so a client cannot
175/// widen past its own company — but leaving the key in lets a caller AND a *second*,
176/// contradictory company predicate and turn every list into an empty-vs-nonempty oracle
177/// for "does company X have rows matching Y". Strip it: a client may narrow **within**
178/// its company, never speak about the company at all.
179///
180/// Handles the snake_case column, its camelCase response key, and both operator forms
181/// (`company_id[eq]=…`).
182pub fn strip_client_company_filters<T: EntityRepoMeta>(filters: &mut HashMap<String, String>) {
183    let Some(column) = T::company_field() else {
184        return;
185    };
186    let camel = snake_to_camel(column);
187    filters.retain(|key, _| {
188        let base = key.split('[').next().unwrap_or(key);
189        !base.eq_ignore_ascii_case(column) && !base.eq_ignore_ascii_case(&camel)
190    });
191}
192
193/// AND two optional SQL conditions into the single base-condition argument the filtered
194/// queries take, so the soft-delete guard and the company fence travel as one string.
195pub fn and_conditions(a: Option<&str>, b: Option<String>) -> Option<String> {
196    match (a, b) {
197        (None, None) => None,
198        (Some(a), None) => Some(a.to_string()),
199        (None, Some(b)) => Some(b),
200        (Some(a), Some(b)) => Some(format!("{a} AND {b}")),
201    }
202}
203
204fn snake_to_camel(s: &str) -> String {
205    let mut out = String::with_capacity(s.len());
206    let mut upper = false;
207    for c in s.chars() {
208        if c == '_' {
209            upper = true;
210        } else if upper {
211            out.push(c.to_ascii_uppercase());
212            upper = false;
213        } else {
214            out.push(c);
215        }
216    }
217    out
218}
219
220/// The not-deleted predicate an update of a soft-delete entity carries, against the alias `t`.
221const ACTIVE_ROW_GUARD: &str = " AND t.metadata->>'deleted_at' IS NULL";
222
223/// Marker: entity uses JSONB-based soft delete (`metadata->>'deleted_at'`).
224///
225/// Active rows satisfy `metadata->>'deleted_at' IS NULL`.
226pub struct SoftDelete;
227
228/// Marker: entity uses hard deletes only — no soft delete / trash concept.
229pub struct HardDelete;
230
231// ─── GenericCrudRepository ────────────────────────────────────────────────────
232
233/// Generic PostgreSQL CRUD repository parameterised by entity type `T` and
234/// delete strategy `D` ([`SoftDelete`] or [`HardDelete`]).
235///
236/// All standard CRUD methods are implemented here once.  Generated repository
237/// structs wrap this type via newtype + `Deref` and only add entity-specific
238/// methods (`find_by_*`, `exists_by_*`, `partial_update`, filtered listings).
239pub struct GenericCrudRepository<T, D = SoftDelete>
240where
241    T: for<'r> FromRow<'r, PgRow> + Send + Unpin,
242{
243    inner: PostgresRepository<T>,
244    _mode: PhantomData<D>,
245}
246
247// ─── Common (both modes) ──────────────────────────────────────────────────────
248
249impl<T, D> GenericCrudRepository<T, D>
250where
251    T: for<'r> FromRow<'r, PgRow> + Send + Unpin,
252{
253    pub fn new(pool: PgPool, table_name: &str) -> Self {
254        Self {
255            inner: PostgresRepository::new(pool, table_name),
256            _mode: PhantomData,
257        }
258    }
259
260    pub fn pool(&self) -> &PgPool {
261        self.inner.pool()
262    }
263
264    pub fn table_name(&self) -> &str {
265        self.inner.table_name()
266    }
267
268    /// Insert a new entity row and return the created row.
269    pub async fn create(&self, entity: &T) -> Result<T>
270    where
271        T: Serialize + Send + Sync,
272    {
273        self.inner.create(entity).await
274    }
275
276    /// Insert multiple entity rows inside a single transaction: all of them, or none.
277    ///
278    /// The first row the database refuses rolls the whole batch back, and the error is a
279    /// [`BatchItemError`] naming that row's position in `entities`. The transaction runs on the
280    /// request's own connection when a request scope holds one, so it carries the same fence
281    /// (company, org units, audit attribution) as a single create.
282    pub async fn bulk_create(&self, entities: &[T]) -> Result<Vec<T>>
283    where
284        T: Serialize + Send + Sync,
285    {
286        if entities.is_empty() {
287            return Ok(Vec::new());
288        }
289        let table = self.table_name();
290        let unit = crate::write_tx::begin_unit(self.pool()).await?;
291        let rows = async {
292            let mut conn = unit.conn().await;
293            insert_each(&mut conn, table, entities).await
294        }
295        .await;
296        unit.finish(rows).await
297    }
298
299    /// Update many rows from `(before, after)` pairs inside one transaction, writing for each
300    /// only the columns that differ — the batch twin of a single changed-columns update — and
301    /// only while `guard` holds (see [`crate::repository::PostgresRepository::update_guarded`]).
302    ///
303    /// A row that no longer matches rolls the whole batch back with a [`BatchItemError`]
304    /// naming its position.
305    async fn bulk_update_changed_guarded(&self, pairs: &[(T, T)], guard: &str) -> Result<Vec<T>>
306    where
307        T: Serialize + Send + Sync,
308    {
309        if pairs.is_empty() {
310            return Ok(Vec::new());
311        }
312        let table = self.table_name();
313        let unit = crate::write_tx::begin_unit(self.pool()).await?;
314        let rows = async {
315            let mut conn = unit.conn().await;
316            update_each_changed(&mut conn, table, guard, pairs).await
317        }
318        .await;
319        unit.finish(rows).await
320    }
321
322    // ── Generic unique-field lookups ──────────────────────────────────────────
323    //
324    // These are shared across both delete modes. The SQL is the same for both;
325    // the soft-delete guard is added separately by the per-mode wrappers below.
326
327    /// Internal: query by a text field with a caller-supplied extra condition.
328    async fn find_by_text_field_with_cond(
329        &self,
330        field: &str,
331        value: &str,
332        extra: &str,
333    ) -> Result<Option<T>> {
334        let query = format!(
335            "SELECT * FROM {} WHERE {} = $1{}",
336            self.table_name(), field, extra
337        );
338        let result = crate::company_scope::fetch_optional_scoped(
339            self.pool(),
340            sqlx::query_as::<_, T>(&query).bind(value),
341        )
342        .await?;
343        Ok(result)
344    }
345
346    /// Internal: existence check by a text field with a caller-supplied extra condition.
347    async fn exists_by_text_field_with_cond(
348        &self,
349        field: &str,
350        value: &str,
351        extra: &str,
352    ) -> Result<bool> {
353        let query = format!(
354            "SELECT 1 FROM {} WHERE {} = $1{} LIMIT 1",
355            self.table_name(), field, extra
356        );
357        let result = crate::company_scope::fetch_optional_scalar_scoped(
358            self.pool(),
359            sqlx::query_scalar::<_, i32>(&query).bind(value),
360        )
361        .await?;
362        Ok(result.is_some())
363    }
364
365    /// Internal: query by a UUID field with a caller-supplied extra condition.
366    async fn find_by_uuid_field_with_cond(
367        &self,
368        field: &str,
369        value: Uuid,
370        extra: &str,
371    ) -> Result<Option<T>> {
372        let query = format!(
373            "SELECT * FROM {} WHERE {} = $1{}",
374            self.table_name(), field, extra
375        );
376        let result = crate::company_scope::fetch_optional_scoped(
377            self.pool(),
378            sqlx::query_as::<_, T>(&query).bind(value),
379        )
380        .await?;
381        Ok(result)
382    }
383
384    /// Internal: existence check by a UUID field with a caller-supplied extra condition.
385    async fn exists_by_uuid_field_with_cond(
386        &self,
387        field: &str,
388        value: Uuid,
389        extra: &str,
390    ) -> Result<bool> {
391        let query = format!(
392            "SELECT 1 FROM {} WHERE {} = $1{} LIMIT 1",
393            self.table_name(), field, extra
394        );
395        let result = crate::company_scope::fetch_optional_scalar_scoped(
396            self.pool(),
397            sqlx::query_scalar::<_, i32>(&query).bind(value),
398        )
399        .await?;
400        Ok(result.is_some())
401    }
402
403    /// Execute a filtered / paginated query against this entity's table.
404    ///
405    /// `base_condition` — when `Some`, raw SQL ANDed as its own parenthesised group ahead of
406    /// the caller-supplied filters, which can therefore never widen past it (not even with
407    /// an `[or]` filter). It must be code-authored; the filters map cannot carry one, since
408    /// the parser ignores every `__`-prefixed key.
409    ///
410    /// NOTE: prefer the `*_scoped` entry points for anything a client can reach —
411    /// they compose the company fence into this condition. This raw form applies no
412    /// fence and is for internal/job callers that have already established scope.
413    pub async fn run_filtered_query(
414        &self,
415        pagination: PaginationParams,
416        base_condition: Option<&str>,
417        filters: &HashMap<String, String>,
418        column_types: &HashMap<String, String>,
419        search_fields: &[&str],
420    ) -> Result<PaginatedResult<T>>
421    where
422        T: Send + Sync,
423    {
424        self.inner
425            .list_with_filters_and_base(
426                pagination,
427                base_condition,
428                filters,
429                column_types,
430                search_fields,
431            )
432            .await
433    }
434
435    /// The aggregate twin of [`Self::run_filtered_query`].
436    ///
437    /// Takes the same `base_condition` so an aggregate is computed over exactly
438    /// the row set the matching list would return — a total that counted
439    /// soft-deleted rows, or another tenant's, would be wrong in a way no
440    /// caller could see.
441    pub async fn run_aggregate_query(
442        &self,
443        spec: &crate::repository::AggregateSpec,
444        base_condition: Option<&str>,
445        filters: &HashMap<String, String>,
446        column_types: &HashMap<String, String>,
447        search_fields: &[&str],
448    ) -> Result<crate::repository::AggregateResult>
449    where
450        T: crate::EntityRepoMeta + Send + Sync,
451    {
452        // Resolve the group column's relation HERE (the only layer that
453        // knows the entity's metadata): a group on a relation FK gets its
454        // target table passed down so the inner query can carry the label.
455        let mut spec = spec.clone();
456        if let (Some(group), Some(label)) = (&spec.group_by, &spec.label_field) {
457            let _ = label;
458            let camel = snake_to_camel(group);
459            if let Some((_, table, _)) = T::relations().iter().find(|(_, _, fk)| *fk == camel) {
460                let base_fk = camel_to_snake(&camel);
461                spec.label_relation = Some((table.to_string(), base_fk));
462            }
463        }
464        self.inner
465            .aggregate_with_filters_and_base(
466                &spec,
467                base_condition,
468                filters,
469                column_types,
470                search_fields,
471            )
472            .await
473    }
474}
475
476// ─── SoftDelete mode ──────────────────────────────────────────────────────────
477
478impl<T> GenericCrudRepository<T, SoftDelete>
479where
480    T: for<'r> FromRow<'r, PgRow> + Send + Unpin,
481{
482    // ── Unique-field lookups (active records only) ────────────────────────────
483
484    /// Find an active entity by a unique text field.
485    ///
486    /// Filters `metadata->>'deleted_at' IS NULL` automatically.
487    pub async fn find_by_text_field(&self, field: &str, value: &str) -> Result<Option<T>> {
488        self.find_by_text_field_with_cond(field, value, " AND metadata->>'deleted_at' IS NULL").await
489    }
490
491    /// Check existence by a unique text field (active records only).
492    pub async fn exists_by_text_field(&self, field: &str, value: &str) -> Result<bool> {
493        self.exists_by_text_field_with_cond(field, value, " AND metadata->>'deleted_at' IS NULL").await
494    }
495
496    /// Find an active entity by a unique UUID field.
497    pub async fn find_by_uuid_field(&self, field: &str, value: Uuid) -> Result<Option<T>> {
498        self.find_by_uuid_field_with_cond(field, value, " AND metadata->>'deleted_at' IS NULL").await
499    }
500
501    /// Check existence by a unique UUID field (active records only).
502    pub async fn exists_by_uuid_field(&self, field: &str, value: Uuid) -> Result<bool> {
503        self.exists_by_uuid_field_with_cond(field, value, " AND metadata->>'deleted_at' IS NULL").await
504    }
505
506    // ── Filtered pagination (requires EntityRepoMeta on T) ────────────────────
507
508    /// Paginate active entities with filter and search support.
509    ///
510    /// Requires `T: EntityRepoMeta` for column type hints and search fields.
511    pub async fn list_paginated_filtered(
512        &self,
513        pagination: PaginationParams,
514        filters: Option<&HashMap<String, String>>,
515    ) -> Result<PaginatedResult<T>>
516    where
517        T: EntityRepoMeta + Send + Sync,
518    {
519        let filters_map = filters.cloned().unwrap_or_default();
520        let column_types = T::column_types();
521        let search_fields_owned: Vec<&'static str> = T::search_fields().iter().copied().collect();
522        self.run_filtered_query(
523            pagination,
524            Some("metadata->>'deleted_at' IS NULL"),
525            &filters_map,
526            &column_types,
527            &search_fields_owned,
528        ).await
529    }
530
531    /// Group and reduce active entities, skipping the soft-deleted.
532    pub async fn aggregate_filtered(
533        &self,
534        spec: &crate::repository::AggregateSpec,
535        filters: Option<&HashMap<String, String>>,
536    ) -> Result<crate::repository::AggregateResult>
537    where
538        T: EntityRepoMeta + Send + Sync,
539    {
540        let filters_map = filters.cloned().unwrap_or_default();
541        let column_types = T::column_types();
542        let search_fields_owned: Vec<&'static str> = T::search_fields().iter().copied().collect();
543        self.run_aggregate_query(spec, Some("metadata->>'deleted_at' IS NULL"), &filters_map, &column_types, &search_fields_owned).await
544    }
545
546    /// Paginate active entities, fenced to `company`.
547    ///
548    /// The company-scoped counterpart of [`Self::list_paginated_filtered`] and the one any
549    /// client-reachable read should use. Fails closed: a fenced entity with no company
550    /// returns [`MissingCompanyScope`] rather than every company's rows.
551    ///
552    /// The fence is ANDed into the same base condition the soft-delete guard uses, so
553    /// it lands in SQL — which is what keeps `total` honest. A post-filter in the handler
554    /// would fetch `limit` rows and return the survivors, leaving callers unable to tell
555    /// "end of data" from "filtered", and could not fix `COUNT` at all.
556    pub async fn list_paginated_filtered_scoped(
557        &self,
558        pagination: PaginationParams,
559        filters: Option<&HashMap<String, String>>,
560        company: Option<Uuid>,
561    ) -> Result<PaginatedResult<T>>
562    where
563        T: EntityRepoMeta + Send + Sync,
564    {
565        let fence = company_fence::<T>(company)?;
566        let mut filters_map = filters.cloned().unwrap_or_default();
567        strip_client_company_filters::<T>(&mut filters_map);
568        let column_types = T::column_types();
569        let search_fields_owned: Vec<&'static str> = T::search_fields().iter().copied().collect();
570        self.run_filtered_query(
571            pagination,
572            and_conditions(Some("metadata->>'deleted_at' IS NULL"), fence).as_deref(),
573            &filters_map,
574            &column_types,
575            &search_fields_owned,
576        )
577        .await
578    }
579
580    /// Paginate soft-deleted entities, fenced to `company`.
581    ///
582    /// `/trash` is the worse leak of the two: it serves rows whose owners were told the
583    /// data is gone. Same fence, same fail-closed contract.
584    pub async fn list_deleted_filtered_scoped(
585        &self,
586        pagination: PaginationParams,
587        filters: Option<&HashMap<String, String>>,
588        company: Option<Uuid>,
589    ) -> Result<PaginatedResult<T>>
590    where
591        T: EntityRepoMeta + Send + Sync,
592    {
593        let fence = company_fence::<T>(company)?;
594        let mut filters_map = filters.cloned().unwrap_or_default();
595        strip_client_company_filters::<T>(&mut filters_map);
596        let column_types = T::column_types();
597        let search_fields: Vec<&'static str> = T::search_fields().to_vec();
598        self.run_filtered_query(
599            pagination,
600            and_conditions(Some("metadata->>'deleted_at' IS NOT NULL"), fence).as_deref(),
601            &filters_map,
602            &column_types,
603            &search_fields,
604        )
605        .await
606    }
607
608    /// Paginate soft-deleted entities with filter and search support — the trash twin of
609    /// [`Self::list_paginated_filtered`].
610    pub async fn list_deleted_filtered(
611        &self,
612        pagination: PaginationParams,
613        filters: Option<&HashMap<String, String>>,
614    ) -> Result<PaginatedResult<T>>
615    where
616        T: EntityRepoMeta + Send + Sync,
617    {
618        let filters_map = filters.cloned().unwrap_or_default();
619        let column_types = T::column_types();
620        let search_fields: Vec<&'static str> = T::search_fields().to_vec();
621        self.run_filtered_query(
622            pagination,
623            Some("metadata->>'deleted_at' IS NOT NULL"),
624            &filters_map,
625            &column_types,
626            &search_fields,
627        ).await
628    }
629
630    /// Find an active (non-deleted) entity by primary key.
631    pub async fn find_by_id(&self, id: &str) -> Result<Option<T>> {
632        let query = format!(
633            "SELECT * FROM {} WHERE id = $1::uuid AND metadata->>'deleted_at' IS NULL",
634            self.table_name()
635        );
636        let result = crate::company_scope::fetch_optional_scoped(
637            self.pool(),
638            sqlx::query_as::<_, T>(&query).bind(id),
639        )
640        .await?;
641        Ok(result)
642    }
643
644    /// Return all active (non-deleted) entities.
645    pub async fn find_all(&self) -> Result<Vec<T>> {
646        let query = format!(
647            "SELECT * FROM {} WHERE metadata->>'deleted_at' IS NULL",
648            self.table_name()
649        );
650        let results = crate::company_scope::fetch_all_scoped(
651            self.pool(),
652            sqlx::query_as::<_, T>(&query),
653        )
654        .await?;
655        Ok(results)
656    }
657
658    /// Full update of an active record; `Ok(None)` when it is missing or soft-deleted.
659    ///
660    /// The not-deleted predicate is part of the UPDATE itself, so a write racing a soft
661    /// delete can never revive the row.
662    pub async fn update(&self, id: &str, entity: &T) -> Result<Option<T>>
663    where
664        T: Serialize + Send + Sync,
665    {
666        self.inner
667            .update_guarded(id, entity, None, ACTIVE_ROW_GUARD)
668            .await
669    }
670
671    /// Update an active record from `before` (the row as read) to `after`, writing only the
672    /// columns whose value differs between the two; `Ok(None)` when the row is missing or was
673    /// soft-deleted since it was read.
674    ///
675    /// The path for a read-modify-write such as a PATCH: a column the caller did not change is
676    /// left as it now stands in the database, so a concurrent write to it (a verb moving a
677    /// status) is not overwritten with the stale value read before it.
678    pub async fn update_changed(&self, id: &str, before: &T, after: &T) -> Result<Option<T>>
679    where
680        T: Serialize + Send + Sync,
681    {
682        let changed = crate::repository::changed_columns(before, after)?;
683        self.inner
684            .update_guarded(id, after, Some(&changed), ACTIVE_ROW_GUARD)
685            .await
686    }
687
688    /// Soft-delete an entity (sets `metadata.deleted_at`).
689    pub async fn delete(&self, id: &str) -> Result<bool> {
690        self.soft_delete(id).await
691    }
692
693    /// Count active (non-deleted) entities.
694    pub async fn count(&self) -> Result<u64> {
695        self.count_active().await
696    }
697
698    /// Return `true` if an active entity with the given ID exists.
699    pub async fn exists(&self, id: &str) -> Result<bool> {
700        let query = format!(
701            "SELECT 1 FROM {} WHERE id = $1::uuid AND metadata->>'deleted_at' IS NULL LIMIT 1",
702            self.table_name()
703        );
704        let result = crate::company_scope::fetch_optional_scalar_scoped(
705            self.pool(),
706            sqlx::query_scalar::<_, i32>(&query).bind(id),
707        )
708        .await?;
709        Ok(result.is_some())
710    }
711
712    /// Paginate active entities, ordered by `id` ascending — the same key order every
713    /// filtered list ends on, so a page never repeats or skips a row between two fetches.
714    pub async fn list_paginated(&self, pagination: PaginationParams) -> Result<PaginatedResult<T>> {
715        let offset = pagination.offset();
716        let limit = pagination.limit();
717        let query = format!(
718            "SELECT * FROM {} WHERE metadata->>'deleted_at' IS NULL \
719             ORDER BY id LIMIT $1 OFFSET $2",
720            self.table_name()
721        );
722        let data = crate::company_scope::fetch_all_scoped(
723            self.pool(),
724            sqlx::query_as::<_, T>(&query).bind(limit as i64).bind(offset as i64),
725        )
726        .await?;
727        let total = self.count_active().await?;
728        Ok(PaginatedResult {
729            data,
730            pagination: PaginationInfo::new(pagination.page, pagination.per_page, total),
731        })
732    }
733
734    // ── Soft-delete helpers ───────────────────────────────────────────────────
735
736    /// Set `metadata.deleted_at` to NOW() (soft delete).
737    pub async fn soft_delete(&self, id: &str) -> Result<bool> {
738        let query = format!(
739            "UPDATE {} SET metadata = jsonb_set(\
740               COALESCE(metadata, '{{}}'), \
741               '{{deleted_at}}', \
742               to_jsonb(NOW())\
743             ) WHERE id = $1::uuid AND (metadata->>'deleted_at') IS NULL",
744            self.table_name()
745        );
746        let result = crate::company_scope::execute_scoped(
747            self.pool(),
748            sqlx::query(&query).bind(id),
749        )
750        .await?;
751        Ok(result.rows_affected() > 0)
752    }
753
754    /// Remove `deleted_at` from metadata, restoring the entity.
755    pub async fn restore(&self, id: &str) -> Result<Option<T>> {
756        let query = format!(
757            "UPDATE {} SET metadata = metadata - 'deleted_at' \
758             WHERE id = $1::uuid AND (metadata->>'deleted_at') IS NOT NULL \
759             RETURNING *",
760            self.table_name()
761        );
762        let result = crate::company_scope::fetch_optional_scoped(
763            self.pool(),
764            sqlx::query_as::<_, T>(&query).bind(id),
765        )
766        .await?;
767        Ok(result)
768    }
769
770    /// Paginate soft-deleted entities (trash view), most recently deleted first, `id` ascending
771    /// among rows deleted at the same instant so the order is total.
772    pub async fn list_deleted(&self, pagination: PaginationParams) -> Result<PaginatedResult<T>> {
773        let offset = pagination.offset();
774        let limit = pagination.limit();
775        let query = format!(
776            "SELECT * FROM {} WHERE (metadata->>'deleted_at') IS NOT NULL \
777             ORDER BY (metadata->>'deleted_at') DESC, id LIMIT $1 OFFSET $2",
778            self.table_name()
779        );
780        let data = crate::company_scope::fetch_all_scoped(
781            self.pool(),
782            sqlx::query_as::<_, T>(&query).bind(limit as i64).bind(offset as i64),
783        )
784        .await?;
785        let count_query = format!(
786            "SELECT COUNT(*) FROM {} WHERE (metadata->>'deleted_at') IS NOT NULL",
787            self.table_name()
788        );
789        let total = crate::company_scope::fetch_one_scalar_scoped(
790            self.pool(),
791            sqlx::query_scalar::<_, i64>(&count_query),
792        )
793        .await? as u64;
794        Ok(PaginatedResult {
795            data,
796            pagination: PaginationInfo::new(pagination.page, pagination.per_page, total),
797        })
798    }
799
800    /// Permanently delete all soft-deleted rows (empty trash).
801    pub async fn empty_trash(&self) -> Result<u64> {
802        let query = format!(
803            "DELETE FROM {} WHERE (metadata->>'deleted_at') IS NOT NULL",
804            self.table_name()
805        );
806        let result = crate::company_scope::execute_scoped(self.pool(), sqlx::query(&query)).await?;
807        Ok(result.rows_affected())
808    }
809
810    /// Find a soft-deleted entity by primary key.
811    pub async fn find_deleted_by_id(&self, id: &str) -> Result<Option<T>> {
812        let query = format!(
813            "SELECT * FROM {} WHERE id = $1::uuid AND (metadata->>'deleted_at') IS NOT NULL",
814            self.table_name()
815        );
816        let result = crate::company_scope::fetch_optional_scoped(
817            self.pool(),
818            sqlx::query_as::<_, T>(&query).bind(id),
819        )
820        .await?;
821        Ok(result)
822    }
823
824    /// Permanently delete a soft-deleted entity by primary key.
825    pub async fn permanent_delete(&self, id: &str) -> Result<bool> {
826        let query = format!(
827            "DELETE FROM {} WHERE id = $1::uuid AND (metadata->>'deleted_at') IS NOT NULL",
828            self.table_name()
829        );
830        let result = crate::company_scope::execute_scoped(
831            self.pool(),
832            sqlx::query(&query).bind(id),
833        )
834        .await?;
835        Ok(result.rows_affected() > 0)
836    }
837
838    /// Count active (non-deleted) entities.
839    pub async fn count_active(&self) -> Result<u64> {
840        let query = format!(
841            "SELECT COUNT(*) FROM {} WHERE (metadata->>'deleted_at') IS NULL",
842            self.table_name()
843        );
844        let count = crate::company_scope::fetch_one_scalar_scoped(
845            self.pool(),
846            sqlx::query_scalar::<_, i64>(&query),
847        )
848        .await? as u64;
849        Ok(count)
850    }
851
852    /// Count soft-deleted entities.
853    pub async fn count_deleted(&self) -> Result<u64> {
854        let query = format!(
855            "SELECT COUNT(*) FROM {} WHERE (metadata->>'deleted_at') IS NOT NULL",
856            self.table_name()
857        );
858        let count = crate::company_scope::fetch_one_scalar_scoped(
859            self.pool(),
860            sqlx::query_scalar::<_, i64>(&query),
861        )
862        .await? as u64;
863        Ok(count)
864    }
865
866    // ── Atomic batch operations ───────────────────────────────────────────────
867    //
868    // Each runs inside a single transaction. For id-list operations the affected
869    // row count must equal the number of ids requested, otherwise the whole batch
870    // is rolled back — all-or-nothing semantics where a missing / already-in-the-
871    // target-state id (or a duplicate id) fails the entire request.
872
873    /// Soft-delete many active rows atomically.
874    pub async fn bulk_soft_delete(&self, ids: &[String]) -> Result<u64> {
875        if ids.is_empty() {
876            return Ok(0);
877        }
878        let placeholders = id_in_placeholders(ids.len());
879        let query = format!(
880            "UPDATE {} SET metadata = jsonb_set(\
881               COALESCE(metadata, '{{}}'), '{{deleted_at}}', to_jsonb(NOW())\
882             ) WHERE id IN ({placeholders}) AND (metadata->>'deleted_at') IS NULL",
883            self.table_name()
884        );
885        let unit = crate::write_tx::begin_unit(self.pool()).await?;
886        let outcome: Result<u64> = async {
887            let mut conn = unit.conn().await;
888            let mut q = sqlx::query(&query);
889            for id in ids {
890                q = q.bind(id);
891            }
892            let affected = q.execute(&mut *conn).await?.rows_affected();
893            if affected != ids.len() as u64 {
894                // Finishing with an error rolls the unit back.
895                return Err(anyhow::anyhow!(
896                    "bulk_soft_delete: {} of {} ids were not active/deletable; rolled back",
897                    ids.len() as u64 - affected,
898                    ids.len()
899                ));
900            }
901            Ok(affected)
902        }
903        .await;
904        unit.finish(outcome).await
905    }
906
907    /// Restore many soft-deleted rows atomically, returning the restored rows.
908    pub async fn bulk_restore(&self, ids: &[String]) -> Result<Vec<T>> {
909        if ids.is_empty() {
910            return Ok(Vec::new());
911        }
912        let placeholders = id_in_placeholders(ids.len());
913        let query = format!(
914            "UPDATE {} SET metadata = metadata - 'deleted_at' \
915             WHERE id IN ({placeholders}) AND (metadata->>'deleted_at') IS NOT NULL \
916             RETURNING *",
917            self.table_name()
918        );
919        let unit = crate::write_tx::begin_unit(self.pool()).await?;
920        let outcome: Result<Vec<T>> = async {
921            let mut conn = unit.conn().await;
922            let mut q = sqlx::query_as::<_, T>(&query);
923            for id in ids {
924                q = q.bind(id);
925            }
926            let rows = q.fetch_all(&mut *conn).await?;
927            if rows.len() != ids.len() {
928                return Err(anyhow::anyhow!(
929                    "bulk_restore: {} of {} ids were not in trash; rolled back",
930                    ids.len() - rows.len(),
931                    ids.len()
932                ));
933            }
934            Ok(rows)
935        }
936        .await;
937        unit.finish(outcome).await
938    }
939
940    /// Permanently delete many soft-deleted rows atomically.
941    pub async fn bulk_permanent_delete(&self, ids: &[String]) -> Result<u64> {
942        if ids.is_empty() {
943            return Ok(0);
944        }
945        let placeholders = id_in_placeholders(ids.len());
946        let query = format!(
947            "DELETE FROM {} WHERE id IN ({placeholders}) AND (metadata->>'deleted_at') IS NOT NULL",
948            self.table_name()
949        );
950        let unit = crate::write_tx::begin_unit(self.pool()).await?;
951        let outcome: Result<u64> = async {
952            let mut conn = unit.conn().await;
953            let mut q = sqlx::query(&query);
954            for id in ids {
955                q = q.bind(id);
956            }
957            let affected = q.execute(&mut *conn).await?.rows_affected();
958            if affected != ids.len() as u64 {
959                return Err(anyhow::anyhow!(
960                    "bulk_permanent_delete: {} of {} ids were not in trash; rolled back",
961                    ids.len() as u64 - affected,
962                    ids.len()
963                ));
964            }
965            Ok(affected)
966        }
967        .await;
968        unit.finish(outcome).await
969    }
970
971    /// Restore every soft-deleted row, returning the restored rows. A single
972    /// `UPDATE ... RETURNING` is atomic, and the returned rows let the service
973    /// layer emit a `Restored` event per entity.
974    pub async fn restore_all(&self) -> Result<Vec<T>> {
975        let query = format!(
976            "UPDATE {} SET metadata = metadata - 'deleted_at' \
977             WHERE (metadata->>'deleted_at') IS NOT NULL \
978             RETURNING *",
979            self.table_name()
980        );
981        let rows = crate::company_scope::fetch_all_scoped(
982            self.pool(),
983            sqlx::query_as::<_, T>(&query),
984        )
985        .await?;
986        Ok(rows)
987    }
988
989    /// Update many active rows atomically. Every entity must reference an
990    /// existing active (non-soft-deleted) row or the whole batch is rolled back.
991    pub async fn bulk_update(&self, entities: &[T]) -> Result<Vec<T>>
992    where
993        T: Serialize + Send + Sync,
994    {
995        bulk_update_rows(
996            self.pool(),
997            self.table_name(),
998            ACTIVE_ROW_GUARD,
999            entities,
1000        )
1001        .await
1002    }
1003
1004    /// Update many active rows from `(before, after)` pairs atomically, writing only the
1005    /// columns each update changed; a row missing or soft-deleted since it was read rolls the
1006    /// whole batch back.
1007    pub async fn bulk_update_changed(&self, pairs: &[(T, T)]) -> Result<Vec<T>>
1008    where
1009        T: Serialize + Send + Sync,
1010    {
1011        self.bulk_update_changed_guarded(pairs, ACTIVE_ROW_GUARD).await
1012    }
1013}
1014
1015/// Build `"$1::uuid, $2::uuid, …"` for an `id IN (…)` clause of `n` bound ids.
1016fn id_in_placeholders(n: usize) -> String {
1017    (1..=n)
1018        .map(|i| format!("${i}::uuid"))
1019        .collect::<Vec<_>>()
1020        .join(", ")
1021}
1022
1023/// One item of a batch write failed, so the whole batch was rolled back.
1024///
1025/// `index` is the item's position in the batch as the caller passed it; `error` is why it
1026/// failed (a database refusal keeps its `sqlx::Error` inside, so a caller can still read the
1027/// constraint it broke).
1028#[derive(Debug)]
1029pub struct BatchItemError {
1030    /// Position of the failed item in the batch.
1031    pub index: usize,
1032    /// Why it failed.
1033    pub error: anyhow::Error,
1034}
1035
1036impl std::fmt::Display for BatchItemError {
1037    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1038        write!(f, "item {} of the batch: {}; rolled back", self.index, self.error)
1039    }
1040}
1041
1042impl std::error::Error for BatchItemError {
1043    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1044        Some(self.error.as_ref())
1045    }
1046}
1047
1048/// Insert each entity on `conn` (an open transaction), stopping at the first failure.
1049async fn insert_each<T>(conn: &mut sqlx::PgConnection, table: &str, entities: &[T]) -> Result<Vec<T>>
1050where
1051    T: for<'r> FromRow<'r, PgRow> + Serialize + Send + Sync + Unpin,
1052{
1053    let mut out = Vec::with_capacity(entities.len());
1054    for (index, entity) in entities.iter().enumerate() {
1055        let (query, json) = crate::repository::insert_statement(table, entity)
1056            .map_err(|error| BatchItemError { index, error })?;
1057        let statement = match json {
1058            Some(json) => sqlx::query_as::<_, T>(&query).bind(json),
1059            None => sqlx::query_as::<_, T>(&query),
1060        };
1061        let row = statement.fetch_one(&mut *conn).await.map_err(|e| BatchItemError {
1062            index,
1063            error: crate::repository::explain_unknown_column(e, table),
1064        })?;
1065        out.push(row);
1066    }
1067    Ok(out)
1068}
1069
1070/// Apply each `(before, after)` pair on `conn` (an open transaction) as a changed-columns
1071/// update under `guard`, stopping at the first row that fails or no longer matches.
1072async fn update_each_changed<T>(
1073    conn: &mut sqlx::PgConnection,
1074    table: &str,
1075    guard: &str,
1076    pairs: &[(T, T)],
1077) -> Result<Vec<T>>
1078where
1079    T: for<'r> FromRow<'r, PgRow> + Serialize + Send + Sync + Unpin,
1080{
1081    use crate::repository::UpdateStatement;
1082    let mut out = Vec::with_capacity(pairs.len());
1083    for (index, (before, after)) in pairs.iter().enumerate() {
1084        let item = |error: anyhow::Error| BatchItemError { index, error };
1085        let id = match serde_json::to_value(after).map_err(|e| item(e.into()))? {
1086            serde_json::Value::Object(obj) => match obj.get("id") {
1087                Some(serde_json::Value::String(id)) => id.clone(),
1088                _ => return Err(item(anyhow::anyhow!("entity missing string 'id' field")).into()),
1089            },
1090            _ => return Err(item(anyhow::anyhow!("entity must serialize to a JSON object")).into()),
1091        };
1092        let changed = crate::repository::changed_columns(before, after).map_err(item)?;
1093        let updated = match crate::repository::update_statement(table, after, Some(&changed), guard)
1094            .map_err(item)?
1095        {
1096            UpdateStatement::Unchanged(query) => {
1097                sqlx::query_as::<_, T>(&query).bind(&id).fetch_optional(&mut *conn).await
1098            }
1099            UpdateStatement::Update { query, json } => {
1100                sqlx::query_as::<_, T>(&query)
1101                    .bind(json)
1102                    .bind(&id)
1103                    .fetch_optional(&mut *conn)
1104                    .await
1105            }
1106        }
1107        .map_err(|e| item(e.into()))?;
1108        match updated {
1109            Some(row) => out.push(row),
1110            None => {
1111                return Err(item(anyhow::anyhow!("id '{id}' not found or already deleted")).into())
1112            }
1113        }
1114    }
1115    Ok(out)
1116}
1117
1118/// Serialize an entity and return `(id, json_string, quoted_column_list)` for the
1119/// `jsonb_populate_record` update query — shared by the per-mode `bulk_update`s.
1120fn build_update_parts<T: Serialize>(entity: &T) -> Result<(String, String, String)> {
1121    let json_value = serde_json::to_value(entity)?;
1122    let json_obj = match json_value {
1123        serde_json::Value::Object(obj) => obj,
1124        _ => return Err(anyhow::anyhow!("entity must serialize to a JSON object")),
1125    };
1126    let id = json_obj
1127        .get("id")
1128        .and_then(|v| v.as_str())
1129        .ok_or_else(|| anyhow::anyhow!("entity missing string 'id' field"))?
1130        .to_string();
1131    let column_names = json_obj
1132        .keys()
1133        .filter(|k| *k != "id")
1134        .map(|k| format!("\"{k}\""))
1135        .collect::<Vec<_>>()
1136        .join(", ");
1137    let json_str = serde_json::to_string(&json_obj)?;
1138    Ok((id, json_str, column_names))
1139}
1140
1141// ─── HardDelete mode ─────────────────────────────────────────────────────────
1142
1143impl<T> GenericCrudRepository<T, HardDelete>
1144where
1145    T: for<'r> FromRow<'r, PgRow> + Send + Sync + Unpin + Serialize,
1146{
1147    // ── Unique-field lookups (no soft-delete guard) ───────────────────────────
1148
1149    /// Find an entity by a unique text field (no soft-delete guard).
1150    pub async fn find_by_text_field(&self, field: &str, value: &str) -> Result<Option<T>> {
1151        self.find_by_text_field_with_cond(field, value, "").await
1152    }
1153
1154    /// Check existence by a unique text field.
1155    pub async fn exists_by_text_field(&self, field: &str, value: &str) -> Result<bool> {
1156        self.exists_by_text_field_with_cond(field, value, "").await
1157    }
1158
1159    /// Find an entity by a unique UUID field.
1160    pub async fn find_by_uuid_field(&self, field: &str, value: Uuid) -> Result<Option<T>> {
1161        self.find_by_uuid_field_with_cond(field, value, "").await
1162    }
1163
1164    /// Check existence by a unique UUID field.
1165    pub async fn exists_by_uuid_field(&self, field: &str, value: Uuid) -> Result<bool> {
1166        self.exists_by_uuid_field_with_cond(field, value, "").await
1167    }
1168
1169    // ── Filtered pagination ───────────────────────────────────────────────────
1170
1171    /// Paginate entities with filter and search support.
1172    pub async fn list_paginated_filtered(
1173        &self,
1174        pagination: PaginationParams,
1175        filters: Option<&HashMap<String, String>>,
1176    ) -> Result<PaginatedResult<T>>
1177    where
1178        T: EntityRepoMeta + Send + Sync,
1179    {
1180        let filters_map = filters.cloned().unwrap_or_default();
1181        let column_types = T::column_types();
1182        let search_fields_owned: Vec<&'static str> = T::search_fields().iter().copied().collect();
1183        self.run_filtered_query(pagination, None, &filters_map, &column_types, &search_fields_owned).await
1184    }
1185
1186    /// Group and reduce entities. This mode has no trash to exclude.
1187    pub async fn aggregate_filtered(
1188        &self,
1189        spec: &crate::repository::AggregateSpec,
1190        filters: Option<&HashMap<String, String>>,
1191    ) -> Result<crate::repository::AggregateResult>
1192    where
1193        T: EntityRepoMeta + Send + Sync,
1194    {
1195        let filters_map = filters.cloned().unwrap_or_default();
1196        let column_types = T::column_types();
1197        let search_fields_owned: Vec<&'static str> = T::search_fields().iter().copied().collect();
1198        self.run_aggregate_query(spec, None, &filters_map, &column_types, &search_fields_owned).await
1199    }
1200
1201    /// Find an entity by primary key.
1202    pub async fn find_by_id(&self, id: &str) -> Result<Option<T>> {
1203        self.inner.find_by_id(id).await
1204    }
1205
1206    /// Return all entities.
1207    pub async fn find_all(&self) -> Result<Vec<T>> {
1208        let query = format!("SELECT * FROM {}", self.table_name());
1209        let results = crate::company_scope::fetch_all_scoped(
1210            self.pool(),
1211            sqlx::query_as::<_, T>(&query),
1212        )
1213        .await?;
1214        Ok(results)
1215    }
1216
1217    /// Full update.
1218    pub async fn update(&self, id: &str, entity: &T) -> Result<Option<T>> {
1219        self.inner.update(id, entity).await
1220    }
1221
1222    /// Update a record from `before` (the row as read) to `after`, writing only the columns
1223    /// whose value differs; `Ok(None)` when the row no longer exists. See the soft-delete
1224    /// twin for why a read-modify-write must not write back the columns it did not change.
1225    pub async fn update_changed(&self, id: &str, before: &T, after: &T) -> Result<Option<T>> {
1226        let changed = crate::repository::changed_columns(before, after)?;
1227        self.inner
1228            .update_guarded(id, after, Some(&changed), "")
1229            .await
1230    }
1231
1232    /// Permanently delete an entity by primary key.
1233    pub async fn delete(&self, id: &str) -> Result<bool> {
1234        self.inner.delete(id).await
1235    }
1236
1237    /// Count all entities.
1238    pub async fn count(&self) -> Result<u64> {
1239        let query = format!("SELECT COUNT(*) FROM {}", self.table_name());
1240        let count = crate::company_scope::fetch_one_scalar_scoped(
1241            self.pool(),
1242            sqlx::query_scalar::<_, i64>(&query),
1243        )
1244        .await? as u64;
1245        Ok(count)
1246    }
1247
1248    /// Return `true` if an entity with the given ID exists.
1249    pub async fn exists(&self, id: &str) -> Result<bool> {
1250        let query = format!(
1251            "SELECT 1 FROM {} WHERE id = $1::uuid LIMIT 1",
1252            self.table_name()
1253        );
1254        let result = crate::company_scope::fetch_optional_scalar_scoped(
1255            self.pool(),
1256            sqlx::query_scalar::<_, i32>(&query).bind(id),
1257        )
1258        .await?;
1259        Ok(result.is_some())
1260    }
1261
1262    /// Paginate entities, ordered by `id` ascending — the same key order every filtered list
1263    /// ends on, so a page never repeats or skips a row between two fetches.
1264    pub async fn list_paginated(&self, pagination: PaginationParams) -> Result<PaginatedResult<T>> {
1265        let offset = pagination.offset();
1266        let limit = pagination.limit();
1267        let query = format!(
1268            "SELECT * FROM {} ORDER BY id LIMIT $1 OFFSET $2",
1269            self.table_name()
1270        );
1271        let data = crate::company_scope::fetch_all_scoped(
1272            self.pool(),
1273            sqlx::query_as::<_, T>(&query).bind(limit as i64).bind(offset as i64),
1274        )
1275        .await?;
1276        let total = self.count().await?;
1277        Ok(PaginatedResult {
1278            data,
1279            pagination: PaginationInfo::new(pagination.page, pagination.per_page, total),
1280        })
1281    }
1282
1283    // ── Atomic batch operations ───────────────────────────────────────────────
1284
1285    /// Hard-delete many rows atomically. Rolls back unless every id matched.
1286    pub async fn bulk_delete(&self, ids: &[String]) -> Result<u64> {
1287        if ids.is_empty() {
1288            return Ok(0);
1289        }
1290        let placeholders = id_in_placeholders(ids.len());
1291        let query = format!(
1292            "DELETE FROM {} WHERE id IN ({placeholders})",
1293            self.table_name()
1294        );
1295        let unit = crate::write_tx::begin_unit(self.pool()).await?;
1296        let outcome: Result<u64> = async {
1297            let mut conn = unit.conn().await;
1298            let mut q = sqlx::query(&query);
1299            for id in ids {
1300                q = q.bind(id);
1301            }
1302            let affected = q.execute(&mut *conn).await?.rows_affected();
1303            if affected != ids.len() as u64 {
1304                return Err(anyhow::anyhow!(
1305                    "bulk_delete: {} of {} ids not found; rolled back",
1306                    ids.len() as u64 - affected,
1307                    ids.len()
1308                ));
1309            }
1310            Ok(affected)
1311        }
1312        .await;
1313        unit.finish(outcome).await
1314    }
1315
1316    /// Update many rows atomically. No soft-delete guard in this mode.
1317    pub async fn bulk_update(&self, entities: &[T]) -> Result<Vec<T>> {
1318        bulk_update_rows(self.pool(), self.table_name(), "", entities).await
1319    }
1320
1321    /// Update many rows from `(before, after)` pairs atomically, writing only the columns each
1322    /// update changed; a row deleted since it was read rolls the whole batch back.
1323    pub async fn bulk_update_changed(&self, pairs: &[(T, T)]) -> Result<Vec<T>> {
1324        self.bulk_update_changed_guarded(pairs, "").await
1325    }
1326}
1327
1328/// Qualify a relation target table with the calling entity's schema.
1329///
1330/// `EntityRepoMeta::relations()` emits bare table names (e.g. `countries`), but
1331/// module tables live in the module's own schema (`geo.countries`) and the
1332/// runtime app role's search_path does not include module schemas — an
1333/// unqualified reference fails with "relation does not exist". The calling
1334/// entity's table name is schema-qualified, so its prefix is the schema a
1335/// relation declared inside one module shares with its target. Targets that
1336/// already carry a schema, and callers that do not, pass through unchanged.
1337pub fn qualify_relation_table(caller_table: &str, target_table: &str) -> String {
1338    if target_table.contains('.') || !caller_table.contains('.') {
1339        target_table.to_string()
1340    } else {
1341        let schema = caller_table.split('.').next().unwrap_or(caller_table);
1342        format!("{schema}.{target_table}")
1343    }
1344}
1345
1346/// True when the error is Postgres `undefined_table` (SQLSTATE 42P01).
1347///
1348/// Walks the anyhow chain because the scoped-fetch error is converted with `?`
1349/// on its way up — the sqlx error rides as the (only) source.
1350fn is_undefined_table(err: &anyhow::Error) -> bool {
1351    err.chain()
1352        .filter_map(|cause| cause.downcast_ref::<sqlx::Error>())
1353        .any(|sqlx_err| {
1354            sqlx_err
1355                .as_database_error()
1356                .map(|db| db.code().as_deref() == Some("42P01"))
1357                .unwrap_or(false)
1358        })
1359}
1360
1361/// Fetch rows from an arbitrary table by id list, as JSON — hydrates `?include=`
1362/// relations in the generic CRUD handler without a typed repository for the
1363/// target entity. `table` is a generator-emitted collection name (from
1364/// `EntityRepoMeta::relations()`), NEVER client input, so the interpolation is
1365/// not an injection vector. `caller_table` is the hydrated entity's own
1366/// (schema-qualified) table name, used to qualify a bare `table` — see
1367/// [`qualify_relation_table`]. One batched `WHERE id = ANY(...)` per relation.
1368/// Returns `row_to_json` objects keyed by raw (snake_case) column names.
1369///
1370/// Resolution is two-step for bare targets: the caller's schema first, then —
1371/// only on `undefined_table` — the name as written, which resolves through the
1372/// role's search_path (platform tables such as `users` or `roles` live in
1373/// `public` while their declaring entities live in a module schema). Any other
1374/// error, or a target that fails both spellings, is returned to the caller.
1375pub async fn fetch_by_ids_as_json(
1376    pool: &PgPool,
1377    caller_table: &str,
1378    table: &str,
1379    ids: &[String],
1380) -> Result<Vec<serde_json::Value>> {
1381    if ids.is_empty() {
1382        return Ok(Vec::new());
1383    }
1384    let qualified = qualify_relation_table(caller_table, table);
1385    match fetch_rows_as_json(pool, &qualified, ids).await {
1386        Ok(rows) => Ok(rows),
1387        Err(err) if qualified != table && is_undefined_table(&err) => {
1388            fetch_rows_as_json(pool, table, ids).await
1389        }
1390        Err(err) => Err(err),
1391    }
1392}
1393
1394/// Whether the row of `table` whose `column` holds `value` is visible on the caller's
1395/// scope: one `SELECT EXISTS` run as every read is (on the request's connection when one
1396/// is bound, else under the task's company scope), so a row row-level security hides
1397/// reads as absent. Postgres checks a foreign key without row-level security; this is
1398/// the check that does not.
1399///
1400/// `value` is a JSON scalar, cast to the column's own type by `jsonb_populate_record`, so
1401/// the lookup keeps the column's index whatever its type. `table` and `column` are
1402/// generator-emitted (`PersistentEntity::references`), never client input, and are refused
1403/// unless they are plain identifiers. A bare `table` resolves as
1404/// [`fetch_by_ids_as_json`]'s does: the caller's schema first, then the search path.
1405/// `Ok(None)` when the table does not exist in either spelling (its module is not
1406/// installed, and the database has no key to it either).
1407pub async fn reference_visible(
1408    pool: &PgPool,
1409    caller_table: &str,
1410    table: &str,
1411    column: &str,
1412    value: &serde_json::Value,
1413) -> Result<Option<bool>> {
1414    let plain = |s: &str, dot: bool| {
1415        !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || (dot && c == '.'))
1416    };
1417    if !plain(table, true) || !plain(column, false) {
1418        anyhow::bail!("reference target `{table}`.`{column}` is not a plain identifier");
1419    }
1420    let qualified = qualify_relation_table(caller_table, table);
1421    match row_visible(pool, &qualified, column, value).await {
1422        Ok(seen) => Ok(Some(seen)),
1423        Err(err) if is_undefined_table(&err) => {
1424            if qualified == table {
1425                return Ok(None);
1426            }
1427            match row_visible(pool, table, column, value).await {
1428                Ok(seen) => Ok(Some(seen)),
1429                Err(err) if is_undefined_table(&err) => Ok(None),
1430                Err(err) => Err(err),
1431            }
1432        }
1433        Err(err) => Err(err),
1434    }
1435}
1436
1437async fn row_visible(
1438    pool: &PgPool,
1439    table: &str,
1440    column: &str,
1441    value: &serde_json::Value,
1442) -> Result<bool> {
1443    let query = format!(
1444        "SELECT EXISTS (SELECT 1 FROM {table} t \
1445         WHERE t.{column} = (jsonb_populate_record(NULL::{table}, $1::jsonb)).{column})"
1446    );
1447    let probe = serde_json::json!({ column: value });
1448    // A missing table fails this statement and the caller tries another spelling. Inside a write
1449    // transaction that failure would abort it, so the lookup runs as a savepoint of it.
1450    Ok(crate::write_tx::within_savepoint(crate::company_scope::fetch_one_scalar_scoped(
1451        pool,
1452        sqlx::query_scalar::<_, bool>(&query).bind(probe),
1453    ))
1454    .await?)
1455}
1456
1457/// The scoped fetch behind [`fetch_by_ids_as_json`] — one relation, one table
1458/// spelling. Scoped because an `?include=` hydration of a company-fenced
1459/// relation must obey the same fence, so a cross-company related row is never
1460/// leaked through the expansion.
1461async fn fetch_rows_as_json(
1462    pool: &PgPool,
1463    table: &str,
1464    ids: &[String],
1465) -> Result<Vec<serde_json::Value>> {
1466    let query = format!("SELECT row_to_json(t) AS j FROM {table} t WHERE t.id = ANY($1::uuid[])");
1467    let rows: Vec<(serde_json::Value,)> =
1468        crate::company_scope::fetch_all_scoped(pool, sqlx::query_as(&query).bind(ids)).await?;
1469    Ok(rows.into_iter().map(|(j,)| j).collect())
1470}
1471
1472/// Shared transactional bulk-update used by both delete modes. Each entity is
1473/// updated by id inside one transaction; a missing (or, with `active_guard`,
1474/// soft-deleted) row rolls the whole batch back. `active_guard` is an extra SQL
1475/// predicate ANDed into the `WHERE` clause (`""` for none).
1476async fn bulk_update_rows<T>(
1477    pool: &PgPool,
1478    table: &str,
1479    active_guard: &str,
1480    entities: &[T],
1481) -> Result<Vec<T>>
1482where
1483    T: for<'r> FromRow<'r, PgRow> + Send + Sync + Unpin + Serialize,
1484{
1485    if entities.is_empty() {
1486        return Ok(Vec::new());
1487    }
1488    let unit = crate::write_tx::begin_unit(pool).await?;
1489    let outcome: Result<Vec<T>> = async {
1490        let mut conn = unit.conn().await;
1491        let mut out = Vec::with_capacity(entities.len());
1492        for entity in entities {
1493            let (id, json_str, column_names) = build_update_parts(entity)?;
1494            let query = format!(
1495                "WITH new_row AS (\
1496                    SELECT (jsonb_populate_record(NULL::{table}, $1::jsonb)).*\
1497                 ) UPDATE {table} AS t \
1498                 SET ({columns}) = (SELECT {columns} FROM new_row) \
1499                 WHERE t.id = $2::uuid{guard} \
1500                 RETURNING t.*",
1501                table = table,
1502                columns = column_names,
1503                guard = active_guard,
1504            );
1505            let updated = sqlx::query_as::<_, T>(&query)
1506                .bind(&json_str)
1507                .bind(&id)
1508                .fetch_optional(&mut *conn)
1509                .await?;
1510            match updated {
1511                Some(e) => out.push(e),
1512                None => {
1513                    return Err(anyhow::anyhow!(
1514                        "bulk_update: id '{id}' not found or already deleted; rolled back"
1515                    ));
1516                }
1517            }
1518        }
1519        Ok(out)
1520    }
1521    .await;
1522    unit.finish(outcome).await
1523}
1524
1525/// camelCase → snake_case (`projectId` → `project_id`), the SQL vocabulary.
1526fn camel_to_snake(s: &str) -> String {
1527    let mut out = String::with_capacity(s.len() + 4);
1528    for ch in s.chars() {
1529        if ch.is_uppercase() {
1530            out.push('_');
1531            out.extend(ch.to_lowercase());
1532        } else {
1533            out.push(ch);
1534        }
1535    }
1536    out
1537}