1use 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
51pub trait EntityRepoMeta {
63 fn column_types() -> HashMap<String, String>;
65 fn search_fields() -> &'static [&'static str];
67
68 fn private_fields() -> &'static [&'static str] {
75 &[]
76 }
77
78 fn secret_fields() -> &'static [&'static str] {
81 &[]
82 }
83
84 fn relation_secret_fields(_relation: &str) -> &'static [&'static str] {
88 &[]
89 }
90
91 fn owner_field() -> Option<&'static str> {
95 None
96 }
97
98 fn company_field() -> Option<&'static str> {
114 None
115 }
116
117 fn relations() -> &'static [(&'static str, &'static str, &'static str)] {
122 &[]
123 }
124}
125
126#[derive(Debug, thiserror::Error)]
136#[error("entity is company-scoped ({column}) but the request carries no company scope")]
137pub struct MissingCompanyScope {
138 pub column: &'static str,
140}
141
142pub 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
161pub 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
183pub 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
212pub struct SoftDelete;
216
217pub struct HardDelete;
219
220pub 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
236impl<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 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 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 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 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 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 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 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 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 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
429impl<T> GenericCrudRepository<T, SoftDelete>
432where
433 T: for<'r> FromRow<'r, PgRow> + Send + Unpin,
434{
435 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 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 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 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 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 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 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 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 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 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 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 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 pub async fn delete(&self, id: &str) -> Result<bool> {
623 self.soft_delete(id).await
624 }
625
626 pub async fn count(&self) -> Result<u64> {
628 self.count_active().await
629 }
630
631 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
927fn id_in_placeholders(n: usize) -> String {
929 (1..=n)
930 .map(|i| format!("${i}::uuid"))
931 .collect::<Vec<_>>()
932 .join(", ")
933}
934
935fn 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
958impl<T> GenericCrudRepository<T, HardDelete>
961where
962 T: for<'r> FromRow<'r, PgRow> + Send + Sync + Unpin + Serialize,
963{
964 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 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 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 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 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 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 pub async fn find_by_id(&self, id: &str) -> Result<Option<T>> {
1020 self.inner.find_by_id(id).await
1021 }
1022
1023 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 pub async fn update(&self, id: &str, entity: &T) -> Result<Option<T>> {
1036 self.inner.update(id, entity).await
1037 }
1038
1039 pub async fn delete(&self, id: &str) -> Result<bool> {
1041 self.inner.delete(id).await
1042 }
1043
1044 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 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 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 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 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
1125pub 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
1143fn 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
1158pub 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
1191async 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
1206async 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
1256fn 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}