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 field_permissions() -> &'static [(&'static str, &'static str)] {
99 &[]
100 }
101
102 fn owner_field() -> Option<&'static str> {
106 None
107 }
108
109 fn company_field() -> Option<&'static str> {
125 None
126 }
127
128 fn relations() -> &'static [(&'static str, &'static str, &'static str)] {
133 &[]
134 }
135}
136
137#[derive(Debug, thiserror::Error)]
147#[error("entity is company-scoped ({column}) but the request carries no company scope")]
148pub struct MissingCompanyScope {
149 pub column: &'static str,
151}
152
153pub 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
171pub 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
193pub 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
220const ACTIVE_ROW_GUARD: &str = " AND t.metadata->>'deleted_at' IS NULL";
222
223pub struct SoftDelete;
227
228pub struct HardDelete;
230
231pub 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
247impl<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 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 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 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 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 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 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 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 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 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 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
476impl<T> GenericCrudRepository<T, SoftDelete>
479where
480 T: for<'r> FromRow<'r, PgRow> + Send + Unpin,
481{
482 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 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 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 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 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 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 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 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 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 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 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 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 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 pub async fn delete(&self, id: &str) -> Result<bool> {
690 self.soft_delete(id).await
691 }
692
693 pub async fn count(&self) -> Result<u64> {
695 self.count_active().await
696 }
697
698 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
1015fn id_in_placeholders(n: usize) -> String {
1017 (1..=n)
1018 .map(|i| format!("${i}::uuid"))
1019 .collect::<Vec<_>>()
1020 .join(", ")
1021}
1022
1023#[derive(Debug)]
1029pub struct BatchItemError {
1030 pub index: usize,
1032 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
1048async 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
1070async 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
1118fn 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
1141impl<T> GenericCrudRepository<T, HardDelete>
1144where
1145 T: for<'r> FromRow<'r, PgRow> + Send + Sync + Unpin + Serialize,
1146{
1147 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 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 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 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 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 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 pub async fn find_by_id(&self, id: &str) -> Result<Option<T>> {
1203 self.inner.find_by_id(id).await
1204 }
1205
1206 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 pub async fn update(&self, id: &str, entity: &T) -> Result<Option<T>> {
1219 self.inner.update(id, entity).await
1220 }
1221
1222 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 pub async fn delete(&self, id: &str) -> Result<bool> {
1234 self.inner.delete(id).await
1235 }
1236
1237 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 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 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 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 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 pub async fn bulk_update_changed(&self, pairs: &[(T, T)]) -> Result<Vec<T>> {
1324 self.bulk_update_changed_guarded(pairs, "").await
1325 }
1326}
1327
1328pub 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
1346fn 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
1361pub 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
1394pub 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 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
1457async 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
1472async 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
1525fn 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}