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    /// The response field (camelCase) holding the owner/company id (`@owner`),
92    /// compared against the caller's access scope to decide private-field
93    /// visibility. `None` = the entity has no owner concept.
94    fn owner_field() -> Option<&'static str> {
95        None
96    }
97
98    // ── Row-level company isolation ──
99
100    /// The DB column that scopes rows to a company (snake_case, e.g. `company_id`).
101    /// `None` = the entity is global (reference data) and is not fenced.
102    ///
103    /// The generator emits this **structurally** — from the presence of the company
104    /// column, a fact it cannot forget — and an entity opts OUT with `@global`.
105    /// The polarity is deliberate: `owner_field()` above is opt-IN via `@owner`, and
106    /// in a year of generated entities not one model ever declared it, so the whole
107    /// field-security path silently never ran. Security that must be remembered, in a
108    /// file nobody edits for security reasons, defaults to open and stays open. Here
109    /// silence means fenced, and forgetting is the safe direction.
110    ///
111    /// This is a *row-visibility fence*, the same category as `SoftDelete`'s
112    /// unconditional `deleted_at IS NULL` — not domain logic.
113    fn company_field() -> Option<&'static str> {
114        None
115    }
116
117    /// To-one relations expandable via `?include=<name>`, as
118    /// `(relation_name, target_table, local_fk_field)` — all response keys
119    /// (relation_name/local_fk in camelCase; target_table is the DB table).
120    /// Default: none.
121    fn relations() -> &'static [(&'static str, &'static str, &'static str)] {
122        &[]
123    }
124}
125
126// ─── Delete-mode markers ──────────────────────────────────────────────────────
127
128// ─── Row-level company fence ───────────────────────────────────────────────────
129
130/// The caller could not be scoped to a company for an entity that requires one.
131///
132/// Deliberately an error and not an empty result: an empty list is a silent lie that
133/// looks identical to "no data" and gets debugged for days, whereas this names the
134/// wiring bug. Callers should surface it as 403.
135#[derive(Debug, thiserror::Error)]
136#[error("entity is company-scoped ({column}) but the request carries no company scope")]
137pub struct MissingCompanyScope {
138    /// The column that would have fenced the query.
139    pub column: &'static str,
140}
141
142/// Build the SQL fence for `T`, or fail closed.
143///
144/// - entity is global (`company_field() == None`) → no fence, reference data reads freely
145/// - entity is fenced but the request has no company → **`Err`**, never an unfenced query
146/// - both present → `col = '<uuid>'`, ANDed into the WHERE by the caller
147///
148/// The literal is safe to interpolate: `column` is generator-emitted (never client
149/// input) and `Uuid`'s `Display` can only produce hex and dashes, so neither side can
150/// carry a quote. The ORM's filter layer has no typed-parameter slot for a base
151/// condition — `__base_condition` is injected verbatim — so this is the seam the
152/// design already established for `SoftDelete`.
153pub fn company_fence<T: EntityRepoMeta>(company: Option<Uuid>) -> Result<Option<String>, MissingCompanyScope> {
154    match (T::company_field(), company) {
155        (None, _) => Ok(None),
156        (Some(column), None) => Err(MissingCompanyScope { column }),
157        (Some(column), Some(id)) => Ok(Some(format!("{column} = '{id}'"))),
158    }
159}
160
161/// Remove any caller-supplied filter on the company column.
162///
163/// The HTTP filter map is `#[serde(flatten)]`, so `?company_id=<victim>` arrives as an
164/// ordinary filter and the builder honours it. The fence is ANDed, so a client cannot
165/// widen past its own company — but leaving the key in lets a caller AND a *second*,
166/// contradictory company predicate and turn every list into an empty-vs-nonempty oracle
167/// for "does company X have rows matching Y". Strip it: a client may narrow **within**
168/// its company, never speak about the company at all.
169///
170/// Handles the snake_case column, its camelCase response key, and both operator forms
171/// (`company_id[eq]=…`).
172pub fn strip_client_company_filters<T: EntityRepoMeta>(filters: &mut HashMap<String, String>) {
173    let Some(column) = T::company_field() else {
174        return;
175    };
176    let camel = snake_to_camel(column);
177    filters.retain(|key, _| {
178        let base = key.split('[').next().unwrap_or(key);
179        !base.eq_ignore_ascii_case(column) && !base.eq_ignore_ascii_case(&camel)
180    });
181}
182
183/// AND two optional SQL conditions into the single `__base_condition` slot.
184///
185/// `__base_condition` is one HashMap key, so the soft-delete guard and the company fence
186/// have to arrive as a single string rather than two independent predicates.
187pub fn and_conditions(a: Option<&str>, b: Option<String>) -> Option<String> {
188    match (a, b) {
189        (None, None) => None,
190        (Some(a), None) => Some(a.to_string()),
191        (None, Some(b)) => Some(b),
192        (Some(a), Some(b)) => Some(format!("{a} AND {b}")),
193    }
194}
195
196fn snake_to_camel(s: &str) -> String {
197    let mut out = String::with_capacity(s.len());
198    let mut upper = false;
199    for c in s.chars() {
200        if c == '_' {
201            upper = true;
202        } else if upper {
203            out.push(c.to_ascii_uppercase());
204            upper = false;
205        } else {
206            out.push(c);
207        }
208    }
209    out
210}
211
212/// Marker: entity uses JSONB-based soft delete (`metadata->>'deleted_at'`).
213///
214/// Active rows satisfy `metadata->>'deleted_at' IS NULL`.
215pub struct SoftDelete;
216
217/// Marker: entity uses hard deletes only — no soft delete / trash concept.
218pub struct HardDelete;
219
220// ─── GenericCrudRepository ────────────────────────────────────────────────────
221
222/// Generic PostgreSQL CRUD repository parameterised by entity type `T` and
223/// delete strategy `D` ([`SoftDelete`] or [`HardDelete`]).
224///
225/// All standard CRUD methods are implemented here once.  Generated repository
226/// structs wrap this type via newtype + `Deref` and only add entity-specific
227/// methods (`find_by_*`, `exists_by_*`, `partial_update`, filtered listings).
228pub struct GenericCrudRepository<T, D = SoftDelete>
229where
230    T: for<'r> FromRow<'r, PgRow> + Send + Unpin,
231{
232    inner: PostgresRepository<T>,
233    _mode: PhantomData<D>,
234}
235
236// ─── Common (both modes) ──────────────────────────────────────────────────────
237
238impl<T, D> GenericCrudRepository<T, D>
239where
240    T: for<'r> FromRow<'r, PgRow> + Send + Unpin,
241{
242    pub fn new(pool: PgPool, table_name: &str) -> Self {
243        Self {
244            inner: PostgresRepository::new(pool, table_name),
245            _mode: PhantomData,
246        }
247    }
248
249    pub fn pool(&self) -> &PgPool {
250        self.inner.pool()
251    }
252
253    pub fn table_name(&self) -> &str {
254        self.inner.table_name()
255    }
256
257    /// Insert a new entity row and return the created row.
258    pub async fn create(&self, entity: &T) -> Result<T>
259    where
260        T: Serialize + Send + Sync,
261    {
262        self.inner.create(entity).await
263    }
264
265    /// Insert multiple entity rows inside a single transaction.
266    pub async fn bulk_create(&self, entities: &[T]) -> Result<Vec<T>>
267    where
268        T: Serialize + Send + Sync,
269    {
270        let tx = sqlx::pool::Pool::begin(self.pool()).await?;
271        let mut results = Vec::with_capacity(entities.len());
272        for entity in entities {
273            results.push(self.create(entity).await?);
274        }
275        tx.commit().await?;
276        Ok(results)
277    }
278
279    // ── Generic unique-field lookups ──────────────────────────────────────────
280    //
281    // These are shared across both delete modes. The SQL is the same for both;
282    // the soft-delete guard is added separately by the per-mode wrappers below.
283
284    /// Internal: query by a text field with a caller-supplied extra condition.
285    async fn find_by_text_field_with_cond(
286        &self,
287        field: &str,
288        value: &str,
289        extra: &str,
290    ) -> Result<Option<T>> {
291        let query = format!(
292            "SELECT * FROM {} WHERE {} = $1{}",
293            self.table_name(), field, extra
294        );
295        let result = crate::company_scope::fetch_optional_scoped(
296            self.pool(),
297            sqlx::query_as::<_, T>(&query).bind(value),
298        )
299        .await?;
300        Ok(result)
301    }
302
303    /// Internal: existence check by a text field with a caller-supplied extra condition.
304    async fn exists_by_text_field_with_cond(
305        &self,
306        field: &str,
307        value: &str,
308        extra: &str,
309    ) -> Result<bool> {
310        let query = format!(
311            "SELECT 1 FROM {} WHERE {} = $1{} LIMIT 1",
312            self.table_name(), field, extra
313        );
314        let result = crate::company_scope::fetch_optional_scalar_scoped(
315            self.pool(),
316            sqlx::query_scalar::<_, i32>(&query).bind(value),
317        )
318        .await?;
319        Ok(result.is_some())
320    }
321
322    /// Internal: query by a UUID field with a caller-supplied extra condition.
323    async fn find_by_uuid_field_with_cond(
324        &self,
325        field: &str,
326        value: Uuid,
327        extra: &str,
328    ) -> Result<Option<T>> {
329        let query = format!(
330            "SELECT * FROM {} WHERE {} = $1{}",
331            self.table_name(), field, extra
332        );
333        let result = crate::company_scope::fetch_optional_scoped(
334            self.pool(),
335            sqlx::query_as::<_, T>(&query).bind(value),
336        )
337        .await?;
338        Ok(result)
339    }
340
341    /// Internal: existence check by a UUID field with a caller-supplied extra condition.
342    async fn exists_by_uuid_field_with_cond(
343        &self,
344        field: &str,
345        value: Uuid,
346        extra: &str,
347    ) -> Result<bool> {
348        let query = format!(
349            "SELECT 1 FROM {} WHERE {} = $1{} LIMIT 1",
350            self.table_name(), field, extra
351        );
352        let result = crate::company_scope::fetch_optional_scalar_scoped(
353            self.pool(),
354            sqlx::query_scalar::<_, i32>(&query).bind(value),
355        )
356        .await?;
357        Ok(result.is_some())
358    }
359
360    /// Execute a filtered / paginated query against this entity's table.
361    ///
362    /// `base_condition` — when `Some`, it is inserted as the `__base_condition`
363    /// filter key which the ORM injects verbatim into the WHERE clause.  Use
364    /// this to add soft-delete guards without touching the caller-supplied
365    /// filters.
366    ///
367    /// NOTE: prefer the `*_scoped` entry points for anything a client can reach —
368    /// they compose the company fence into this condition. This raw form applies no
369    /// fence and is for internal/job callers that have already established scope.
370    pub async fn run_filtered_query(
371        &self,
372        pagination: PaginationParams,
373        base_condition: Option<&str>,
374        filters: &HashMap<String, String>,
375        column_types: &HashMap<String, String>,
376        search_fields: &[&str],
377    ) -> Result<PaginatedResult<T>>
378    where
379        T: Send + Sync,
380    {
381        let mut filters_map = filters.clone();
382        if let Some(cond) = base_condition {
383            filters_map.insert("__base_condition".to_string(), cond.to_string());
384        }
385        self.inner
386            .list_with_filters(pagination, &filters_map, column_types, search_fields)
387            .await
388    }
389
390    /// The aggregate twin of [`Self::run_filtered_query`].
391    ///
392    /// Takes the same `base_condition` so an aggregate is computed over exactly
393    /// the row set the matching list would return — a total that counted
394    /// soft-deleted rows, or another tenant's, would be wrong in a way no
395    /// caller could see.
396    pub async fn run_aggregate_query(
397        &self,
398        spec: &crate::repository::AggregateSpec,
399        base_condition: Option<&str>,
400        filters: &HashMap<String, String>,
401        column_types: &HashMap<String, String>,
402        search_fields: &[&str],
403    ) -> Result<crate::repository::AggregateResult>
404    where
405        T: crate::EntityRepoMeta + Send + Sync,
406    {
407        // Resolve the group column's relation HERE (the only layer that
408        // knows the entity's metadata): a group on a relation FK gets its
409        // target table passed down so the inner query can carry the label.
410        let mut spec = spec.clone();
411        if let (Some(group), Some(label)) = (&spec.group_by, &spec.label_field) {
412            let _ = label;
413            let camel = snake_to_camel(group);
414            if let Some((_, table, _)) = T::relations().iter().find(|(_, _, fk)| *fk == camel) {
415                let base_fk = camel_to_snake(&camel);
416                spec.label_relation = Some((table.to_string(), base_fk));
417            }
418        }
419        let mut filters_map = filters.clone();
420        if let Some(cond) = base_condition {
421            filters_map.insert("__base_condition".to_string(), cond.to_string());
422        }
423        self.inner
424            .aggregate_with_filters(&spec, &filters_map, column_types, search_fields)
425            .await
426    }
427}
428
429// ─── SoftDelete mode ──────────────────────────────────────────────────────────
430
431impl<T> GenericCrudRepository<T, SoftDelete>
432where
433    T: for<'r> FromRow<'r, PgRow> + Send + Unpin,
434{
435    // ── Unique-field lookups (active records only) ────────────────────────────
436
437    /// Find an active entity by a unique text field.
438    ///
439    /// Filters `metadata->>'deleted_at' IS NULL` automatically.
440    pub async fn find_by_text_field(&self, field: &str, value: &str) -> Result<Option<T>> {
441        self.find_by_text_field_with_cond(field, value, " AND metadata->>'deleted_at' IS NULL").await
442    }
443
444    /// Check existence by a unique text field (active records only).
445    pub async fn exists_by_text_field(&self, field: &str, value: &str) -> Result<bool> {
446        self.exists_by_text_field_with_cond(field, value, " AND metadata->>'deleted_at' IS NULL").await
447    }
448
449    /// Find an active entity by a unique UUID field.
450    pub async fn find_by_uuid_field(&self, field: &str, value: Uuid) -> Result<Option<T>> {
451        self.find_by_uuid_field_with_cond(field, value, " AND metadata->>'deleted_at' IS NULL").await
452    }
453
454    /// Check existence by a unique UUID field (active records only).
455    pub async fn exists_by_uuid_field(&self, field: &str, value: Uuid) -> Result<bool> {
456        self.exists_by_uuid_field_with_cond(field, value, " AND metadata->>'deleted_at' IS NULL").await
457    }
458
459    // ── Filtered pagination (requires EntityRepoMeta on T) ────────────────────
460
461    /// Paginate active entities with filter and search support.
462    ///
463    /// Requires `T: EntityRepoMeta` for column type hints and search fields.
464    pub async fn list_paginated_filtered(
465        &self,
466        pagination: PaginationParams,
467        filters: Option<&HashMap<String, String>>,
468    ) -> Result<PaginatedResult<T>>
469    where
470        T: EntityRepoMeta + Send + Sync,
471    {
472        let filters_map = filters.cloned().unwrap_or_default();
473        let column_types = T::column_types();
474        let search_fields_owned: Vec<&'static str> = T::search_fields().iter().copied().collect();
475        self.run_filtered_query(
476            pagination,
477            Some("metadata->>'deleted_at' IS NULL"),
478            &filters_map,
479            &column_types,
480            &search_fields_owned,
481        ).await
482    }
483
484    /// Group and reduce active entities, skipping the soft-deleted.
485    pub async fn aggregate_filtered(
486        &self,
487        spec: &crate::repository::AggregateSpec,
488        filters: Option<&HashMap<String, String>>,
489    ) -> Result<crate::repository::AggregateResult>
490    where
491        T: EntityRepoMeta + Send + Sync,
492    {
493        let filters_map = filters.cloned().unwrap_or_default();
494        let column_types = T::column_types();
495        let search_fields_owned: Vec<&'static str> = T::search_fields().iter().copied().collect();
496        self.run_aggregate_query(spec, Some("metadata->>'deleted_at' IS NULL"), &filters_map, &column_types, &search_fields_owned).await
497    }
498
499    /// Paginate active entities, fenced to `company`.
500    ///
501    /// The company-scoped counterpart of [`Self::list_paginated_filtered`] and the one any
502    /// client-reachable read should use. Fails closed: a fenced entity with no company
503    /// returns [`MissingCompanyScope`] rather than every company's rows.
504    ///
505    /// The fence is ANDed into the same `__base_condition` the soft-delete guard uses, so
506    /// it lands in SQL — which is what keeps `total` honest. A post-filter in the handler
507    /// would fetch `limit` rows and return the survivors, leaving callers unable to tell
508    /// "end of data" from "filtered", and could not fix `COUNT` at all.
509    pub async fn list_paginated_filtered_scoped(
510        &self,
511        pagination: PaginationParams,
512        filters: Option<&HashMap<String, String>>,
513        company: Option<Uuid>,
514    ) -> Result<PaginatedResult<T>>
515    where
516        T: EntityRepoMeta + Send + Sync,
517    {
518        let fence = company_fence::<T>(company)?;
519        let mut filters_map = filters.cloned().unwrap_or_default();
520        strip_client_company_filters::<T>(&mut filters_map);
521        let column_types = T::column_types();
522        let search_fields_owned: Vec<&'static str> = T::search_fields().iter().copied().collect();
523        self.run_filtered_query(
524            pagination,
525            and_conditions(Some("metadata->>'deleted_at' IS NULL"), fence).as_deref(),
526            &filters_map,
527            &column_types,
528            &search_fields_owned,
529        )
530        .await
531    }
532
533    /// Paginate soft-deleted entities, fenced to `company`.
534    ///
535    /// `/trash` is the worse leak of the two: it serves rows whose owners were told the
536    /// data is gone. Same fence, same fail-closed contract.
537    pub async fn list_deleted_filtered_scoped(
538        &self,
539        pagination: PaginationParams,
540        filters: Option<&HashMap<String, String>>,
541        company: Option<Uuid>,
542    ) -> Result<PaginatedResult<T>>
543    where
544        T: EntityRepoMeta + Send + Sync,
545    {
546        let fence = company_fence::<T>(company)?;
547        let mut filters_map = filters.cloned().unwrap_or_default();
548        strip_client_company_filters::<T>(&mut filters_map);
549        let column_types = T::column_types();
550        let empty: &[&str] = &[];
551        self.run_filtered_query(
552            pagination,
553            and_conditions(Some("metadata->>'deleted_at' IS NOT NULL"), fence).as_deref(),
554            &filters_map,
555            &column_types,
556            empty,
557        )
558        .await
559    }
560
561    /// Paginate soft-deleted entities with filter support.
562    pub async fn list_deleted_filtered(
563        &self,
564        pagination: PaginationParams,
565        filters: Option<&HashMap<String, String>>,
566    ) -> Result<PaginatedResult<T>>
567    where
568        T: EntityRepoMeta + Send + Sync,
569    {
570        let filters_map = filters.cloned().unwrap_or_default();
571        let column_types = T::column_types();
572        let empty: &[&str] = &[];
573        self.run_filtered_query(
574            pagination,
575            Some("metadata->>'deleted_at' IS NOT NULL"),
576            &filters_map,
577            &column_types,
578            empty,
579        ).await
580    }
581
582    /// Find an active (non-deleted) entity by primary key.
583    pub async fn find_by_id(&self, id: &str) -> Result<Option<T>> {
584        let query = format!(
585            "SELECT * FROM {} WHERE id = $1::uuid AND metadata->>'deleted_at' IS NULL",
586            self.table_name()
587        );
588        let result = crate::company_scope::fetch_optional_scoped(
589            self.pool(),
590            sqlx::query_as::<_, T>(&query).bind(id),
591        )
592        .await?;
593        Ok(result)
594    }
595
596    /// Return all active (non-deleted) entities.
597    pub async fn find_all(&self) -> Result<Vec<T>> {
598        let query = format!(
599            "SELECT * FROM {} WHERE metadata->>'deleted_at' IS NULL",
600            self.table_name()
601        );
602        let results = crate::company_scope::fetch_all_scoped(
603            self.pool(),
604            sqlx::query_as::<_, T>(&query),
605        )
606        .await?;
607        Ok(results)
608    }
609
610    /// Full update — skips silently if the record is already soft-deleted.
611    pub async fn update(&self, id: &str, entity: &T) -> Result<Option<T>>
612    where
613        T: Serialize + Send + Sync,
614    {
615        if self.find_by_id(id).await?.is_none() {
616            return Ok(None);
617        }
618        self.inner.update(id, entity).await
619    }
620
621    /// Soft-delete an entity (sets `metadata.deleted_at`).
622    pub async fn delete(&self, id: &str) -> Result<bool> {
623        self.soft_delete(id).await
624    }
625
626    /// Count active (non-deleted) entities.
627    pub async fn count(&self) -> Result<u64> {
628        self.count_active().await
629    }
630
631    /// Return `true` if an active entity with the given ID exists.
632    pub async fn exists(&self, id: &str) -> Result<bool> {
633        let query = format!(
634            "SELECT 1 FROM {} WHERE id = $1::uuid AND metadata->>'deleted_at' IS NULL LIMIT 1",
635            self.table_name()
636        );
637        let result = crate::company_scope::fetch_optional_scalar_scoped(
638            self.pool(),
639            sqlx::query_scalar::<_, i32>(&query).bind(id),
640        )
641        .await?;
642        Ok(result.is_some())
643    }
644
645    /// Paginate active entities (most-recent-first by ID).
646    pub async fn list_paginated(&self, pagination: PaginationParams) -> Result<PaginatedResult<T>> {
647        let offset = pagination.offset();
648        let limit = pagination.limit();
649        let query = format!(
650            "SELECT * FROM {} WHERE metadata->>'deleted_at' IS NULL \
651             ORDER BY id DESC LIMIT $1 OFFSET $2",
652            self.table_name()
653        );
654        let data = crate::company_scope::fetch_all_scoped(
655            self.pool(),
656            sqlx::query_as::<_, T>(&query).bind(limit as i64).bind(offset as i64),
657        )
658        .await?;
659        let total = self.count_active().await?;
660        Ok(PaginatedResult {
661            data,
662            pagination: PaginationInfo::new(pagination.page, pagination.per_page, total),
663        })
664    }
665
666    // ── Soft-delete helpers ───────────────────────────────────────────────────
667
668    /// Set `metadata.deleted_at` to NOW() (soft delete).
669    pub async fn soft_delete(&self, id: &str) -> Result<bool> {
670        let query = format!(
671            "UPDATE {} SET metadata = jsonb_set(\
672               COALESCE(metadata, '{{}}'), \
673               '{{deleted_at}}', \
674               to_jsonb(NOW())\
675             ) WHERE id = $1::uuid AND (metadata->>'deleted_at') IS NULL",
676            self.table_name()
677        );
678        let result = crate::company_scope::execute_scoped(
679            self.pool(),
680            sqlx::query(&query).bind(id),
681        )
682        .await?;
683        Ok(result.rows_affected() > 0)
684    }
685
686    /// Remove `deleted_at` from metadata, restoring the entity.
687    pub async fn restore(&self, id: &str) -> Result<Option<T>> {
688        let query = format!(
689            "UPDATE {} SET metadata = metadata - 'deleted_at' \
690             WHERE id = $1::uuid AND (metadata->>'deleted_at') IS NOT NULL \
691             RETURNING *",
692            self.table_name()
693        );
694        let result = crate::company_scope::fetch_optional_scoped(
695            self.pool(),
696            sqlx::query_as::<_, T>(&query).bind(id),
697        )
698        .await?;
699        Ok(result)
700    }
701
702    /// Paginate soft-deleted entities (trash view).
703    pub async fn list_deleted(&self, pagination: PaginationParams) -> Result<PaginatedResult<T>> {
704        let offset = pagination.offset();
705        let limit = pagination.limit();
706        let query = format!(
707            "SELECT * FROM {} WHERE (metadata->>'deleted_at') IS NOT NULL \
708             ORDER BY (metadata->>'deleted_at') DESC LIMIT $1 OFFSET $2",
709            self.table_name()
710        );
711        let data = crate::company_scope::fetch_all_scoped(
712            self.pool(),
713            sqlx::query_as::<_, T>(&query).bind(limit as i64).bind(offset as i64),
714        )
715        .await?;
716        let count_query = format!(
717            "SELECT COUNT(*) FROM {} WHERE (metadata->>'deleted_at') IS NOT NULL",
718            self.table_name()
719        );
720        let total = crate::company_scope::fetch_one_scalar_scoped(
721            self.pool(),
722            sqlx::query_scalar::<_, i64>(&count_query),
723        )
724        .await? as u64;
725        Ok(PaginatedResult {
726            data,
727            pagination: PaginationInfo::new(pagination.page, pagination.per_page, total),
728        })
729    }
730
731    /// Permanently delete all soft-deleted rows (empty trash).
732    pub async fn empty_trash(&self) -> Result<u64> {
733        let query = format!(
734            "DELETE FROM {} WHERE (metadata->>'deleted_at') IS NOT NULL",
735            self.table_name()
736        );
737        let result = crate::company_scope::execute_scoped(self.pool(), sqlx::query(&query)).await?;
738        Ok(result.rows_affected())
739    }
740
741    /// Find a soft-deleted entity by primary key.
742    pub async fn find_deleted_by_id(&self, id: &str) -> Result<Option<T>> {
743        let query = format!(
744            "SELECT * FROM {} WHERE id = $1::uuid AND (metadata->>'deleted_at') IS NOT NULL",
745            self.table_name()
746        );
747        let result = crate::company_scope::fetch_optional_scoped(
748            self.pool(),
749            sqlx::query_as::<_, T>(&query).bind(id),
750        )
751        .await?;
752        Ok(result)
753    }
754
755    /// Permanently delete a soft-deleted entity by primary key.
756    pub async fn permanent_delete(&self, id: &str) -> Result<bool> {
757        let query = format!(
758            "DELETE FROM {} WHERE id = $1::uuid AND (metadata->>'deleted_at') IS NOT NULL",
759            self.table_name()
760        );
761        let result = crate::company_scope::execute_scoped(
762            self.pool(),
763            sqlx::query(&query).bind(id),
764        )
765        .await?;
766        Ok(result.rows_affected() > 0)
767    }
768
769    /// Count active (non-deleted) entities.
770    pub async fn count_active(&self) -> Result<u64> {
771        let query = format!(
772            "SELECT COUNT(*) FROM {} WHERE (metadata->>'deleted_at') IS NULL",
773            self.table_name()
774        );
775        let count = crate::company_scope::fetch_one_scalar_scoped(
776            self.pool(),
777            sqlx::query_scalar::<_, i64>(&query),
778        )
779        .await? as u64;
780        Ok(count)
781    }
782
783    /// Count soft-deleted entities.
784    pub async fn count_deleted(&self) -> Result<u64> {
785        let query = format!(
786            "SELECT COUNT(*) FROM {} WHERE (metadata->>'deleted_at') IS NOT NULL",
787            self.table_name()
788        );
789        let count = crate::company_scope::fetch_one_scalar_scoped(
790            self.pool(),
791            sqlx::query_scalar::<_, i64>(&query),
792        )
793        .await? as u64;
794        Ok(count)
795    }
796
797    // ── Atomic batch operations ───────────────────────────────────────────────
798    //
799    // Each runs inside a single transaction. For id-list operations the affected
800    // row count must equal the number of ids requested, otherwise the whole batch
801    // is rolled back — all-or-nothing semantics where a missing / already-in-the-
802    // target-state id (or a duplicate id) fails the entire request.
803
804    /// Soft-delete many active rows atomically.
805    pub async fn bulk_soft_delete(&self, ids: &[String]) -> Result<u64> {
806        if ids.is_empty() {
807            return Ok(0);
808        }
809        let placeholders = id_in_placeholders(ids.len());
810        let query = format!(
811            "UPDATE {} SET metadata = jsonb_set(\
812               COALESCE(metadata, '{{}}'), '{{deleted_at}}', to_jsonb(NOW())\
813             ) WHERE id IN ({placeholders}) AND (metadata->>'deleted_at') IS NULL",
814            self.table_name()
815        );
816        let mut tx = self.pool().begin().await?;
817        crate::company_scope::bind_current_company(&mut tx).await?;
818        let mut q = sqlx::query(&query);
819        for id in ids {
820            q = q.bind(id);
821        }
822        let affected = q.execute(&mut *tx).await?.rows_affected();
823        if affected != ids.len() as u64 {
824            // `tx` is dropped here without commit → rolled back.
825            return Err(anyhow::anyhow!(
826                "bulk_soft_delete: {} of {} ids were not active/deletable; rolled back",
827                ids.len() as u64 - affected,
828                ids.len()
829            ));
830        }
831        tx.commit().await?;
832        Ok(affected)
833    }
834
835    /// Restore many soft-deleted rows atomically, returning the restored rows.
836    pub async fn bulk_restore(&self, ids: &[String]) -> Result<Vec<T>> {
837        if ids.is_empty() {
838            return Ok(Vec::new());
839        }
840        let placeholders = id_in_placeholders(ids.len());
841        let query = format!(
842            "UPDATE {} SET metadata = metadata - 'deleted_at' \
843             WHERE id IN ({placeholders}) AND (metadata->>'deleted_at') IS NOT NULL \
844             RETURNING *",
845            self.table_name()
846        );
847        let mut tx = self.pool().begin().await?;
848        crate::company_scope::bind_current_company(&mut tx).await?;
849        let mut q = sqlx::query_as::<_, T>(&query);
850        for id in ids {
851            q = q.bind(id);
852        }
853        let rows = q.fetch_all(&mut *tx).await?;
854        if rows.len() != ids.len() {
855            return Err(anyhow::anyhow!(
856                "bulk_restore: {} of {} ids were not in trash; rolled back",
857                ids.len() - rows.len(),
858                ids.len()
859            ));
860        }
861        tx.commit().await?;
862        Ok(rows)
863    }
864
865    /// Permanently delete many soft-deleted rows atomically.
866    pub async fn bulk_permanent_delete(&self, ids: &[String]) -> Result<u64> {
867        if ids.is_empty() {
868            return Ok(0);
869        }
870        let placeholders = id_in_placeholders(ids.len());
871        let query = format!(
872            "DELETE FROM {} WHERE id IN ({placeholders}) AND (metadata->>'deleted_at') IS NOT NULL",
873            self.table_name()
874        );
875        let mut tx = self.pool().begin().await?;
876        crate::company_scope::bind_current_company(&mut tx).await?;
877        let mut q = sqlx::query(&query);
878        for id in ids {
879            q = q.bind(id);
880        }
881        let affected = q.execute(&mut *tx).await?.rows_affected();
882        if affected != ids.len() as u64 {
883            return Err(anyhow::anyhow!(
884                "bulk_permanent_delete: {} of {} ids were not in trash; rolled back",
885                ids.len() as u64 - affected,
886                ids.len()
887            ));
888        }
889        tx.commit().await?;
890        Ok(affected)
891    }
892
893    /// Restore every soft-deleted row, returning the restored rows. A single
894    /// `UPDATE ... RETURNING` is atomic, and the returned rows let the service
895    /// layer emit a `Restored` event per entity.
896    pub async fn restore_all(&self) -> Result<Vec<T>> {
897        let query = format!(
898            "UPDATE {} SET metadata = metadata - 'deleted_at' \
899             WHERE (metadata->>'deleted_at') IS NOT NULL \
900             RETURNING *",
901            self.table_name()
902        );
903        let rows = crate::company_scope::fetch_all_scoped(
904            self.pool(),
905            sqlx::query_as::<_, T>(&query),
906        )
907        .await?;
908        Ok(rows)
909    }
910
911    /// Update many active rows atomically. Every entity must reference an
912    /// existing active (non-soft-deleted) row or the whole batch is rolled back.
913    pub async fn bulk_update(&self, entities: &[T]) -> Result<Vec<T>>
914    where
915        T: Serialize + Send + Sync,
916    {
917        bulk_update_rows(
918            self.pool(),
919            self.table_name(),
920            " AND t.metadata->>'deleted_at' IS NULL",
921            entities,
922        )
923        .await
924    }
925}
926
927/// Build `"$1::uuid, $2::uuid, …"` for an `id IN (…)` clause of `n` bound ids.
928fn id_in_placeholders(n: usize) -> String {
929    (1..=n)
930        .map(|i| format!("${i}::uuid"))
931        .collect::<Vec<_>>()
932        .join(", ")
933}
934
935/// Serialize an entity and return `(id, json_string, quoted_column_list)` for the
936/// `jsonb_populate_record` update query — shared by the per-mode `bulk_update`s.
937fn build_update_parts<T: Serialize>(entity: &T) -> Result<(String, String, String)> {
938    let json_value = serde_json::to_value(entity)?;
939    let json_obj = match json_value {
940        serde_json::Value::Object(obj) => obj,
941        _ => return Err(anyhow::anyhow!("entity must serialize to a JSON object")),
942    };
943    let id = json_obj
944        .get("id")
945        .and_then(|v| v.as_str())
946        .ok_or_else(|| anyhow::anyhow!("entity missing string 'id' field"))?
947        .to_string();
948    let column_names = json_obj
949        .keys()
950        .filter(|k| *k != "id")
951        .map(|k| format!("\"{k}\""))
952        .collect::<Vec<_>>()
953        .join(", ");
954    let json_str = serde_json::to_string(&json_obj)?;
955    Ok((id, json_str, column_names))
956}
957
958// ─── HardDelete mode ─────────────────────────────────────────────────────────
959
960impl<T> GenericCrudRepository<T, HardDelete>
961where
962    T: for<'r> FromRow<'r, PgRow> + Send + Sync + Unpin + Serialize,
963{
964    // ── Unique-field lookups (no soft-delete guard) ───────────────────────────
965
966    /// Find an entity by a unique text field (no soft-delete guard).
967    pub async fn find_by_text_field(&self, field: &str, value: &str) -> Result<Option<T>> {
968        self.find_by_text_field_with_cond(field, value, "").await
969    }
970
971    /// Check existence by a unique text field.
972    pub async fn exists_by_text_field(&self, field: &str, value: &str) -> Result<bool> {
973        self.exists_by_text_field_with_cond(field, value, "").await
974    }
975
976    /// Find an entity by a unique UUID field.
977    pub async fn find_by_uuid_field(&self, field: &str, value: Uuid) -> Result<Option<T>> {
978        self.find_by_uuid_field_with_cond(field, value, "").await
979    }
980
981    /// Check existence by a unique UUID field.
982    pub async fn exists_by_uuid_field(&self, field: &str, value: Uuid) -> Result<bool> {
983        self.exists_by_uuid_field_with_cond(field, value, "").await
984    }
985
986    // ── Filtered pagination ───────────────────────────────────────────────────
987
988    /// Paginate entities with filter and search support.
989    pub async fn list_paginated_filtered(
990        &self,
991        pagination: PaginationParams,
992        filters: Option<&HashMap<String, String>>,
993    ) -> Result<PaginatedResult<T>>
994    where
995        T: EntityRepoMeta + Send + Sync,
996    {
997        let filters_map = filters.cloned().unwrap_or_default();
998        let column_types = T::column_types();
999        let search_fields_owned: Vec<&'static str> = T::search_fields().iter().copied().collect();
1000        self.run_filtered_query(pagination, None, &filters_map, &column_types, &search_fields_owned).await
1001    }
1002
1003    /// Group and reduce entities. This mode has no trash to exclude.
1004    pub async fn aggregate_filtered(
1005        &self,
1006        spec: &crate::repository::AggregateSpec,
1007        filters: Option<&HashMap<String, String>>,
1008    ) -> Result<crate::repository::AggregateResult>
1009    where
1010        T: EntityRepoMeta + Send + Sync,
1011    {
1012        let filters_map = filters.cloned().unwrap_or_default();
1013        let column_types = T::column_types();
1014        let search_fields_owned: Vec<&'static str> = T::search_fields().iter().copied().collect();
1015        self.run_aggregate_query(spec, None, &filters_map, &column_types, &search_fields_owned).await
1016    }
1017
1018    /// Find an entity by primary key.
1019    pub async fn find_by_id(&self, id: &str) -> Result<Option<T>> {
1020        self.inner.find_by_id(id).await
1021    }
1022
1023    /// Return all entities.
1024    pub async fn find_all(&self) -> Result<Vec<T>> {
1025        let query = format!("SELECT * FROM {}", self.table_name());
1026        let results = crate::company_scope::fetch_all_scoped(
1027            self.pool(),
1028            sqlx::query_as::<_, T>(&query),
1029        )
1030        .await?;
1031        Ok(results)
1032    }
1033
1034    /// Full update.
1035    pub async fn update(&self, id: &str, entity: &T) -> Result<Option<T>> {
1036        self.inner.update(id, entity).await
1037    }
1038
1039    /// Permanently delete an entity by primary key.
1040    pub async fn delete(&self, id: &str) -> Result<bool> {
1041        self.inner.delete(id).await
1042    }
1043
1044    /// Count all entities.
1045    pub async fn count(&self) -> Result<u64> {
1046        let query = format!("SELECT COUNT(*) FROM {}", self.table_name());
1047        let count = crate::company_scope::fetch_one_scalar_scoped(
1048            self.pool(),
1049            sqlx::query_scalar::<_, i64>(&query),
1050        )
1051        .await? as u64;
1052        Ok(count)
1053    }
1054
1055    /// Return `true` if an entity with the given ID exists.
1056    pub async fn exists(&self, id: &str) -> Result<bool> {
1057        let query = format!(
1058            "SELECT 1 FROM {} WHERE id = $1::uuid LIMIT 1",
1059            self.table_name()
1060        );
1061        let result = crate::company_scope::fetch_optional_scalar_scoped(
1062            self.pool(),
1063            sqlx::query_scalar::<_, i32>(&query).bind(id),
1064        )
1065        .await?;
1066        Ok(result.is_some())
1067    }
1068
1069    /// Paginate entities (most-recent-first by ID).
1070    pub async fn list_paginated(&self, pagination: PaginationParams) -> Result<PaginatedResult<T>> {
1071        let offset = pagination.offset();
1072        let limit = pagination.limit();
1073        let query = format!(
1074            "SELECT * FROM {} ORDER BY id DESC LIMIT $1 OFFSET $2",
1075            self.table_name()
1076        );
1077        let data = crate::company_scope::fetch_all_scoped(
1078            self.pool(),
1079            sqlx::query_as::<_, T>(&query).bind(limit as i64).bind(offset as i64),
1080        )
1081        .await?;
1082        let total = self.count().await?;
1083        Ok(PaginatedResult {
1084            data,
1085            pagination: PaginationInfo::new(pagination.page, pagination.per_page, total),
1086        })
1087    }
1088
1089    // ── Atomic batch operations ───────────────────────────────────────────────
1090
1091    /// Hard-delete many rows atomically. Rolls back unless every id matched.
1092    pub async fn bulk_delete(&self, ids: &[String]) -> Result<u64> {
1093        if ids.is_empty() {
1094            return Ok(0);
1095        }
1096        let placeholders = id_in_placeholders(ids.len());
1097        let query = format!(
1098            "DELETE FROM {} WHERE id IN ({placeholders})",
1099            self.table_name()
1100        );
1101        let mut tx = self.pool().begin().await?;
1102        crate::company_scope::bind_current_company(&mut tx).await?;
1103        let mut q = sqlx::query(&query);
1104        for id in ids {
1105            q = q.bind(id);
1106        }
1107        let affected = q.execute(&mut *tx).await?.rows_affected();
1108        if affected != ids.len() as u64 {
1109            return Err(anyhow::anyhow!(
1110                "bulk_delete: {} of {} ids not found; rolled back",
1111                ids.len() as u64 - affected,
1112                ids.len()
1113            ));
1114        }
1115        tx.commit().await?;
1116        Ok(affected)
1117    }
1118
1119    /// Update many rows atomically. No soft-delete guard in this mode.
1120    pub async fn bulk_update(&self, entities: &[T]) -> Result<Vec<T>> {
1121        bulk_update_rows(self.pool(), self.table_name(), "", entities).await
1122    }
1123}
1124
1125/// Qualify a relation target table with the calling entity's schema.
1126///
1127/// `EntityRepoMeta::relations()` emits bare table names (e.g. `countries`), but
1128/// module tables live in the module's own schema (`geo.countries`) and the
1129/// runtime app role's search_path does not include module schemas — an
1130/// unqualified reference fails with "relation does not exist". The calling
1131/// entity's table name is schema-qualified, so its prefix is the schema a
1132/// relation declared inside one module shares with its target. Targets that
1133/// already carry a schema, and callers that do not, pass through unchanged.
1134pub fn qualify_relation_table(caller_table: &str, target_table: &str) -> String {
1135    if target_table.contains('.') || !caller_table.contains('.') {
1136        target_table.to_string()
1137    } else {
1138        let schema = caller_table.split('.').next().unwrap_or(caller_table);
1139        format!("{schema}.{target_table}")
1140    }
1141}
1142
1143/// True when the error is Postgres `undefined_table` (SQLSTATE 42P01).
1144///
1145/// Walks the anyhow chain because the scoped-fetch error is converted with `?`
1146/// on its way up — the sqlx error rides as the (only) source.
1147fn is_undefined_table(err: &anyhow::Error) -> bool {
1148    err.chain()
1149        .filter_map(|cause| cause.downcast_ref::<sqlx::Error>())
1150        .any(|sqlx_err| {
1151            sqlx_err
1152                .as_database_error()
1153                .map(|db| db.code().as_deref() == Some("42P01"))
1154                .unwrap_or(false)
1155        })
1156}
1157
1158/// Fetch rows from an arbitrary table by id list, as JSON — hydrates `?include=`
1159/// relations in the generic CRUD handler without a typed repository for the
1160/// target entity. `table` is a generator-emitted collection name (from
1161/// `EntityRepoMeta::relations()`), NEVER client input, so the interpolation is
1162/// not an injection vector. `caller_table` is the hydrated entity's own
1163/// (schema-qualified) table name, used to qualify a bare `table` — see
1164/// [`qualify_relation_table`]. One batched `WHERE id = ANY(...)` per relation.
1165/// Returns `row_to_json` objects keyed by raw (snake_case) column names.
1166///
1167/// Resolution is two-step for bare targets: the caller's schema first, then —
1168/// only on `undefined_table` — the name as written, which resolves through the
1169/// role's search_path (platform tables such as `users` or `roles` live in
1170/// `public` while their declaring entities live in a module schema). Any other
1171/// error, or a target that fails both spellings, is returned to the caller.
1172pub async fn fetch_by_ids_as_json(
1173    pool: &PgPool,
1174    caller_table: &str,
1175    table: &str,
1176    ids: &[String],
1177) -> Result<Vec<serde_json::Value>> {
1178    if ids.is_empty() {
1179        return Ok(Vec::new());
1180    }
1181    let qualified = qualify_relation_table(caller_table, table);
1182    match fetch_rows_as_json(pool, &qualified, ids).await {
1183        Ok(rows) => Ok(rows),
1184        Err(err) if qualified != table && is_undefined_table(&err) => {
1185            fetch_rows_as_json(pool, table, ids).await
1186        }
1187        Err(err) => Err(err),
1188    }
1189}
1190
1191/// The scoped fetch behind [`fetch_by_ids_as_json`] — one relation, one table
1192/// spelling. Scoped because an `?include=` hydration of a company-fenced
1193/// relation must obey the same fence, so a cross-company related row is never
1194/// leaked through the expansion.
1195async fn fetch_rows_as_json(
1196    pool: &PgPool,
1197    table: &str,
1198    ids: &[String],
1199) -> Result<Vec<serde_json::Value>> {
1200    let query = format!("SELECT row_to_json(t) AS j FROM {table} t WHERE t.id = ANY($1::uuid[])");
1201    let rows: Vec<(serde_json::Value,)> =
1202        crate::company_scope::fetch_all_scoped(pool, sqlx::query_as(&query).bind(ids)).await?;
1203    Ok(rows.into_iter().map(|(j,)| j).collect())
1204}
1205
1206/// Shared transactional bulk-update used by both delete modes. Each entity is
1207/// updated by id inside one transaction; a missing (or, with `active_guard`,
1208/// soft-deleted) row rolls the whole batch back. `active_guard` is an extra SQL
1209/// predicate ANDed into the `WHERE` clause (`""` for none).
1210async fn bulk_update_rows<T>(
1211    pool: &PgPool,
1212    table: &str,
1213    active_guard: &str,
1214    entities: &[T],
1215) -> Result<Vec<T>>
1216where
1217    T: for<'r> FromRow<'r, PgRow> + Send + Sync + Unpin + Serialize,
1218{
1219    if entities.is_empty() {
1220        return Ok(Vec::new());
1221    }
1222    let mut tx = pool.begin().await?;
1223    crate::company_scope::bind_current_company(&mut tx).await?;
1224    let mut out = Vec::with_capacity(entities.len());
1225    for entity in entities {
1226        let (id, json_str, column_names) = build_update_parts(entity)?;
1227        let query = format!(
1228            "WITH new_row AS (\
1229                SELECT (jsonb_populate_record(NULL::{table}, $1::jsonb)).*\
1230             ) UPDATE {table} AS t \
1231             SET ({columns}) = (SELECT {columns} FROM new_row) \
1232             WHERE t.id = $2::uuid{guard} \
1233             RETURNING t.*",
1234            table = table,
1235            columns = column_names,
1236            guard = active_guard,
1237        );
1238        let updated = sqlx::query_as::<_, T>(&query)
1239            .bind(&json_str)
1240            .bind(&id)
1241            .fetch_optional(&mut *tx)
1242            .await?;
1243        match updated {
1244            Some(e) => out.push(e),
1245            None => {
1246                return Err(anyhow::anyhow!(
1247                    "bulk_update: id '{id}' not found or already deleted; rolled back"
1248                ));
1249            }
1250        }
1251    }
1252    tx.commit().await?;
1253    Ok(out)
1254}
1255
1256/// camelCase → snake_case (`projectId` → `project_id`), the SQL vocabulary.
1257fn camel_to_snake(s: &str) -> String {
1258    let mut out = String::with_capacity(s.len() + 4);
1259    for ch in s.chars() {
1260        if ch.is_uppercase() {
1261            out.push('_');
1262            out.extend(ch.to_lowercase());
1263        } else {
1264            out.push(ch);
1265        }
1266    }
1267    out
1268}