1use async_trait::async_trait;
4use sqlx::{PgPool, FromRow, postgres::PgRow, Postgres};
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use chrono::NaiveDateTime;
8use std::collections::{HashMap, HashSet};
9
10use crate::qualify_relation_table;
11use crate::filter::{parse_filters as parse_query_filter};
12use crate::filter::SortDirection as FilterSortDirection;
13use sqlx::Row as _;
14
15pub trait Entity {
17 fn id(&self) -> Option<&str>;
19
20 fn table_name() -> &'static str where Self: Sized;
22
23 fn is_deleted(&self) -> bool { false }
25
26 fn created_at(&self) -> Option<NaiveDateTime> { None }
28
29 fn updated_at(&self) -> Option<NaiveDateTime> { None }
31}
32
33#[derive(Debug, Clone, Default)]
35pub struct PaginationParams {
36 pub page: u32,
37 pub per_page: u32,
38}
39
40impl PaginationParams {
41 pub const MAX_PER_PAGE: u32 = 100;
44
45 pub fn new(page: u32, per_page: u32) -> Self {
49 Self {
50 page: page.max(1),
51 per_page: per_page.clamp(1, Self::MAX_PER_PAGE),
52 }
53 }
54
55 pub fn offset(&self) -> u32 {
58 self.page.saturating_sub(1).saturating_mul(self.per_page)
59 }
60
61 pub fn limit(&self) -> u32 {
62 self.per_page
63 }
64}
65
66#[derive(Debug, Clone, Default)]
68pub struct SortParams {
69 pub field: String,
70 pub direction: SortDirection,
71}
72
73#[derive(Debug, Clone, Default)]
74pub enum SortDirection {
75 #[default]
76 Asc,
77 Desc,
78}
79
80#[derive(Debug, Clone, Default)]
82pub struct FilterParams {
83 pub conditions: HashMap<String, FilterCondition>,
84}
85
86#[derive(Debug, Clone)]
87pub enum FilterCondition {
88 Equals(String),
89 NotEquals(String),
90 GreaterThan(String),
91 LessThan(String),
92 Like(String),
93 In(Vec<String>),
94 IsNull,
95 IsNotNull,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct PaginatedResult<T> {
101 pub data: Vec<T>,
102 pub pagination: PaginationInfo,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct PaginationInfo {
107 pub page: u32,
108 pub per_page: u32,
109 pub total: u64,
110 pub total_pages: u32,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub next_cursor: Option<String>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub prev_cursor: Option<String>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub has_more: Option<bool>,
123 #[serde(default = "default_count_mode")]
127 pub count_mode: String,
128}
129
130pub const EXACT_COUNT_BELOW: u64 = 10_000;
133
134fn default_count_mode() -> String {
135 "exact".to_string()
136}
137
138impl PaginationInfo {
139 pub fn new(page: u32, per_page: u32, total: u64) -> Self {
140 let total_pages = ((total as f64) / (per_page as f64)).ceil() as u32;
141 Self {
142 page,
143 per_page,
144 total,
145 total_pages,
146 next_cursor: None,
147 prev_cursor: None,
148 has_more: None,
149 count_mode: default_count_mode(),
150 }
151 }
152}
153
154#[async_trait]
156pub trait DatabaseOperations<T: for<'a> FromRow<'a, PgRow> + Send + Unpin> {
157 async fn create(&self, entity: &T) -> anyhow::Result<T>;
159
160 async fn find_by_id(&self, id: &str) -> anyhow::Result<Option<T>>;
162
163 async fn find_all(&self) -> anyhow::Result<Vec<T>>;
165
166 async fn update(&self, id: &str, entity: &T) -> anyhow::Result<Option<T>>;
168
169 async fn delete(&self, id: &str) -> anyhow::Result<bool>;
171
172 async fn count(&self) -> anyhow::Result<u64>;
174
175 async fn exists(&self, id: &str) -> anyhow::Result<bool>;
177
178 async fn execute_query(&self, query: &str) -> anyhow::Result<u64>;
180}
181
182pub struct PostgresRepository<T: for<'a> FromRow<'a, PgRow> + Send + Unpin> {
184 pool: PgPool,
185 table_name: String,
186 _phantom: std::marker::PhantomData<T>,
187}
188
189impl<T: for<'a> FromRow<'a, PgRow> + Send + Unpin> PostgresRepository<T> {
190 pub fn new(pool: PgPool, table_name: &str) -> Self {
191 Self {
192 pool,
193 table_name: table_name.to_string(),
194 _phantom: std::marker::PhantomData,
195 }
196 }
197
198 pub fn pool(&self) -> &PgPool {
199 &self.pool
200 }
201
202 pub fn table_name(&self) -> &str {
203 &self.table_name
204 }
205
206 pub async fn list_with_filters(
262 &self,
263 pagination: PaginationParams,
264 filters: &HashMap<String, String>,
265 column_types: &HashMap<String, String>,
266 search_fields: &[&str],
267 ) -> anyhow::Result<PaginatedResult<T>>
268 where
269 T: Send + Sync,
270 {
271 self.list_with_filters_and_base(pagination, None, filters, column_types, search_fields)
272 .await
273 }
274
275 pub(crate) async fn list_with_filters_and_base(
282 &self,
283 pagination: PaginationParams,
284 base_condition: Option<&str>,
285 filters: &HashMap<String, String>,
286 column_types: &HashMap<String, String>,
287 search_fields: &[&str],
288 ) -> anyhow::Result<PaginatedResult<T>>
289 where
290 T: Send + Sync,
291 {
292 let mut query_filter = self.parse_typed_filters(filters, column_types, None).await?;
294 if let Some(cond) = base_condition {
295 query_filter.add_base_condition(cond);
296 }
297
298 if !search_fields.is_empty() {
300 query_filter.search_fields = search_fields.iter().map(|s| s.to_string()).collect();
301 }
302
303 self.execute_list(pagination, query_filter).await
304 }
305
306 pub async fn list_with_filters_whitelisted(
338 &self,
339 pagination: PaginationParams,
340 filters: &HashMap<String, String>,
341 column_types: &HashMap<String, String>,
342 search_fields: &[&str],
343 allowed_fields: Option<&HashSet<String>>,
344 ) -> anyhow::Result<PaginatedResult<T>>
345 where
346 T: Send + Sync,
347 {
348 let mut query_filter =
350 self.parse_typed_filters(filters, column_types, allowed_fields).await?;
351
352 if !search_fields.is_empty() {
354 query_filter.search_fields = search_fields.iter().map(|s| s.to_string()).collect();
355 }
356
357 self.execute_list(pagination, query_filter).await
358 }
359
360 pub async fn update_guarded(
372 &self,
373 id: &str,
374 entity: &T,
375 only: Option<&[String]>,
376 guard: &str,
377 ) -> anyhow::Result<Option<T>>
378 where
379 T: Serialize + Send + Sync,
380 {
381 let result = match update_statement(&self.table_name, entity, only, guard)? {
382 UpdateStatement::Unchanged(query) => {
383 crate::company_scope::fetch_optional_scoped(
384 &self.pool,
385 sqlx::query_as::<_, T>(&query).bind(id),
386 )
387 .await?
388 }
389 UpdateStatement::Update { query, json } => {
390 crate::company_scope::fetch_optional_scoped(
391 &self.pool,
392 sqlx::query_as::<_, T>(&query).bind(json).bind(id),
393 )
394 .await?
395 }
396 };
397
398 Ok(result)
399 }
400
401 async fn parse_typed_filters(
416 &self,
417 filters: &HashMap<String, String>,
418 column_types: &HashMap<String, String>,
419 allowed_fields: Option<&HashSet<String>>,
420 ) -> anyhow::Result<crate::QueryFilter> {
421 let query_filter = parse_query_filter(filters, column_types, allowed_fields)?;
422 if !query_filter.needs_catalog_types() {
423 return Ok(query_filter);
424 }
425 let catalog = catalog_filter_casts(&self.pool, &self.table_name).await?;
426 let merged = merge_filter_casts(column_types, catalog);
427 parse_query_filter(filters, &merged, allowed_fields)
428 }
429
430 #[allow(clippy::type_complexity)]
444 async fn execute_list(
445 &self,
446 pagination: PaginationParams,
447 query_filter: crate::QueryFilter,
448 ) -> anyhow::Result<PaginatedResult<T>> {
449 let limit = pagination.limit() as i64;
450 let backwards =
451 query_filter.cursor_before.is_some() && query_filter.cursor_after.is_none();
452 let cursor_walk = query_filter.cursor_after.is_some() || backwards;
453
454 let (mut where_clause, mut filter_params) = query_filter.build_where_clause();
455 let order_clause;
456 let mut boundary_casts: Vec<Option<String>> = Vec::new();
458
459 let mut sorts: Vec<(String, FilterSortDirection)> = query_filter
464 .sorts
465 .iter()
466 .map(|s| (s.field.clone(), s.direction.clone()))
467 .collect();
468 if cursor_walk || !sorts.is_empty() {
469 if sorts.is_empty() {
470 sorts.push(("id".into(), FilterSortDirection::Asc));
471 } else if sorts.last().map(|(f, _)| f != "id").unwrap_or(true) {
472 sorts.push(("id".into(), FilterSortDirection::Asc));
473 }
474 }
475
476 if !sorts.is_empty() {
479 boundary_casts = self.sort_column_casts(&sorts).await?;
480 }
481
482 if cursor_walk {
483 let opaque = if backwards {
484 query_filter.cursor_before.clone().unwrap()
485 } else {
486 query_filter.cursor_after.clone().unwrap()
487 };
488 let payload = crate::filter::cursor::decode_cursor(&opaque, &sorts)
489 .map_err(|e| anyhow::anyhow!("cursor refused: {e}"))?;
490 let mut idx = filter_params.len() + 1;
491 let (keyset_sql, keyset_params) = crate::filter::cursor::build_keyset_predicate(
492 &payload,
493 &mut idx,
494 &boundary_casts,
495 backwards,
496 );
497 where_clause = and_keyset_predicate(&where_clause, &keyset_sql);
498 filter_params.extend(keyset_params);
499 let parts: Vec<String> = sorts
500 .iter()
501 .map(|(f, d)| {
502 let dir = if (*d == FilterSortDirection::Desc) != backwards {
503 "DESC"
504 } else {
505 "ASC"
506 };
507 format!("{} {}", f, dir)
508 })
509 .collect();
510 order_clause = format!(" ORDER BY {}", parts.join(", "));
511 } else {
512 if sorts.is_empty() {
515 order_clause = query_filter.build_order_by_clause();
516 } else {
517 let parts: Vec<String> = sorts
518 .iter()
519 .map(|(f, d)| {
520 let dir =
521 if *d == FilterSortDirection::Desc { "DESC" } else { "ASC" };
522 format!("{} {}", f, dir)
523 })
524 .collect();
525 order_clause = format!(" ORDER BY {}", parts.join(", "));
526 }
527 }
528 let boundary_sorts: Vec<(String, FilterSortDirection)> = sorts;
530
531 let (total, count_mode) = if query_filter.estimate_total {
537 let estimate = self.estimate_filtered_rows(&where_clause, &filter_params).await?;
538 if estimate <= EXACT_COUNT_BELOW {
539 (self.count_filtered_rows(&where_clause, &filter_params).await?, "exact")
540 } else {
541 (estimate, "estimate")
542 }
543 } else if cursor_walk {
544 (0u64, "none")
545 } else {
546 (self.count_filtered_rows(&where_clause, &filter_params).await?, "exact")
547 };
548
549 let fetch = limit + 1;
552 let data_query = if cursor_walk {
553 format!(
554 "SELECT * FROM {}{}{} LIMIT {}",
555 self.table_name, where_clause, order_clause, fetch
556 )
557 } else {
558 format!(
559 "SELECT * FROM {}{}{} LIMIT {} OFFSET {}",
560 self.table_name,
561 where_clause,
562 order_clause,
563 fetch,
564 pagination.offset()
565 )
566 };
567
568 let mut pagination_info = PaginationInfo::new(pagination.page, pagination.per_page, total);
569 pagination_info.count_mode = count_mode.to_string();
570
571 let mut rows_query = sqlx::query(&data_query);
576 for param in &filter_params {
577 rows_query = rows_query.bind(param);
578 }
579 let rows: Vec<PgRow> =
580 crate::company_scope::fetch_all_rows_scoped(&self.pool, rows_query).await?;
581 let has_more = rows.len() as i64 > limit;
582 let mut page: Vec<PgRow> = rows.into_iter().take(limit as usize).collect();
583 if backwards {
584 page.reverse();
585 }
586 let data: anyhow::Result<Vec<T>> = page
587 .iter()
588 .map(|row| T::from_row(row).map_err(|e| anyhow::anyhow!("decode row: {e}")))
589 .collect();
590 let data = data?;
591
592 let deterministic = !boundary_sorts.is_empty();
596 let next_cursor = if (has_more || backwards) && deterministic {
597 page.last().and_then(|r| {
598 let casts = if boundary_casts.is_empty() {
599 &boundary_casts
602 } else {
603 &boundary_casts
604 };
605 self.row_cursor(r, &boundary_sorts, casts)
606 })
607 } else {
608 None
609 };
610 let prev_cursor = if !page.is_empty() && deterministic {
611 page.first().and_then(|r| self.row_cursor(r, &boundary_sorts, &boundary_casts))
612 } else {
613 None
614 };
615
616 pagination_info.has_more = Some(has_more);
617 pagination_info.next_cursor = next_cursor;
618 pagination_info.prev_cursor = prev_cursor;
619
620 Ok(PaginatedResult {
621 data,
622 pagination: pagination_info,
623 })
624 }
625
626 async fn count_filtered_rows(
631 &self,
632 where_clause: &str,
633 filter_params: &[String],
634 ) -> anyhow::Result<u64> {
635 let count_query = format!("SELECT COUNT(*) FROM {}{}", self.table_name, where_clause);
636 let mut count_query_builder = sqlx::query_scalar::<_, i64>(&count_query);
637 for param in filter_params {
638 count_query_builder = count_query_builder.bind(param);
639 }
640 Ok(crate::company_scope::fetch_one_scalar_scoped(&self.pool, count_query_builder).await?
641 as u64)
642 }
643
644 async fn estimate_filtered_rows(
645 &self,
646 where_clause: &str,
647 filter_params: &[String],
648 ) -> anyhow::Result<u64> {
649 let explain = format!("EXPLAIN (FORMAT JSON) SELECT 1 FROM {}{}", self.table_name, where_clause);
650 let mut builder = sqlx::query_scalar::<_, serde_json::Value>(&explain);
651 for param in filter_params {
652 builder = builder.bind(param);
653 }
654 let plan: serde_json::Value =
655 crate::company_scope::fetch_one_scalar_scoped(&self.pool, builder).await?;
656 let rows = plan
657 .as_array()
658 .and_then(|a| a.first())
659 .and_then(|top| top.get("Plan"))
660 .and_then(|p| p.get("Plan Rows"))
661 .and_then(|r| r.as_i64())
662 .unwrap_or(0);
663 Ok(rows.max(0) as u64)
664 }
665
666 async fn sort_column_casts(
671 &self,
672 sorts: &[(String, FilterSortDirection)],
673 ) -> anyhow::Result<Vec<Option<String>>> {
674 let (schema, table) = match self.table_name.rsplit_once('.') {
675 Some((s, t)) => (s.to_string(), t.to_string()),
676 None => ("public".to_string(), self.table_name.clone()),
677 };
678 let mut casts: Vec<Option<String>> = Vec::with_capacity(sorts.len());
679 for (field, _) in sorts {
680 let row: Option<(String, String, String)> = sqlx::query_as(
681 "SELECT data_type, coalesce(udt_name, ''), coalesce(udt_schema, '') \
682 FROM information_schema.columns \
683 WHERE table_schema = $1 AND table_name = $2 AND column_name = $3",
684 )
685 .bind(&schema)
686 .bind(&table)
687 .bind(field)
688 .fetch_optional(&self.pool)
689 .await?;
690 let cast = row
691 .map(|(data_type, udt, udt_schema)| cast_suffix(&data_type, &udt, &udt_schema))
692 .flatten();
693 casts.push(cast);
694 }
695 Ok(casts)
696 }
697
698 fn row_cursor(
702 &self,
703 row: &PgRow,
704 sorts: &[(String, FilterSortDirection)],
705 casts: &[Option<String>],
706 ) -> Option<String> {
707 let id: uuid::Uuid = row.try_get("id").ok()?;
708 let mut values: Vec<serde_json::Value> = Vec::with_capacity(sorts.len());
709 for (i, (field, _)) in sorts.iter().enumerate() {
710 let field = field.as_str();
711 let mut data_type = casts.get(i).and_then(|c| c.as_deref()).unwrap_or("");
712 if field == "id" && data_type.is_empty() {
716 data_type = "uuid";
717 }
718 let text: Option<String> = match data_type {
719 "numeric" => row
720 .try_get::<Option<sqlx::types::Decimal>, _>(field)
721 .ok()?
722 .map(|d| d.to_string()),
723 "uuid" => row
724 .try_get::<Option<uuid::Uuid>, _>(field)
725 .ok()?
726 .map(|u| u.to_string()),
727 "timestamptz" => row
728 .try_get::<Option<chrono::DateTime<chrono::Utc>>, _>(field)
729 .ok()?
730 .map(|t| t.to_rfc3339()),
731 "integer" | "smallint" => row
732 .try_get::<Option<i32>, _>(field)
733 .ok()?
734 .map(|n| n.to_string()),
735 "bigint" => row
736 .try_get::<Option<i64>, _>(field)
737 .ok()?
738 .map(|n| n.to_string()),
739 "boolean" => row
740 .try_get::<Option<bool>, _>(field)
741 .ok()?
742 .map(|b| b.to_string()),
743 "date" => row
744 .try_get::<Option<chrono::NaiveDate>, _>(field)
745 .ok()?
746 .map(|d| d.to_string()),
747 _ => row
748 .try_get::<Option<String>, _>(field)
749 .ok()?
750 .filter(|s| !s.is_empty() || data_type.is_empty()),
751 };
752 values.push(serde_json::Value::String(text?));
753 }
754 crate::filter::cursor::encode_cursor(sorts, &values, &id.to_string()).ok()
755 }
756}
757
758pub(crate) enum UpdateStatement {
762 Unchanged(String),
764 Update { query: String, json: String },
766}
767
768pub(crate) fn update_statement<T: Serialize>(
770 table: &str,
771 entity: &T,
772 only: Option<&[String]>,
773 guard: &str,
774) -> anyhow::Result<UpdateStatement> {
775 let json_obj = match serde_json::to_value(entity)? {
776 Value::Object(obj) => obj,
777 _ => return Err(anyhow::anyhow!("Entity must serialize to a JSON object")),
778 };
779
780 let update_columns: Vec<&String> = json_obj
781 .keys()
782 .filter(|k| *k != "id")
783 .filter(|k| only.is_none_or(|cols| cols.iter().any(|c| c == *k)))
784 .collect();
785
786 if update_columns.is_empty() {
787 return Ok(UpdateStatement::Unchanged(format!(
788 "SELECT t.* FROM {table} AS t WHERE t.id = $1::uuid{guard}"
789 )));
790 }
791
792 let column_names = update_columns
793 .iter()
794 .map(|k| quote_ident(k))
795 .collect::<Vec<_>>()
796 .join(", ");
797 let json = serde_json::to_string(&json_obj)?;
798
799 let query = format!(
802 r#"
803 WITH new_row AS (
804 SELECT (jsonb_populate_record(NULL::{table}, $1::jsonb)).*
805 )
806 UPDATE {table} AS t
807 SET ({columns}) = (SELECT {columns} FROM new_row)
808 WHERE t.id = $2::uuid{guard}
809 RETURNING t.*
810 "#,
811 table = table,
812 columns = column_names,
813 guard = guard,
814 );
815 Ok(UpdateStatement::Update { query, json })
816}
817
818pub(crate) fn insert_statement<T: Serialize>(
821 table: &str,
822 entity: &T,
823) -> anyhow::Result<(String, Option<String>)> {
824 let json_obj = match serde_json::to_value(entity)? {
825 Value::Object(obj) => obj,
826 _ => return Err(anyhow::anyhow!("Entity must serialize to a JSON object")),
827 };
828
829 let insert_columns: Vec<String> = json_obj.keys().map(|k| quote_ident(k)).collect();
843
844 if insert_columns.is_empty() {
845 return Ok((format!("INSERT INTO {table} DEFAULT VALUES RETURNING *"), None));
848 }
849 let columns = insert_columns.join(", ");
850 let query = format!(
851 r#"
852 INSERT INTO {table} ({columns})
853 SELECT {columns} FROM jsonb_populate_record(NULL::{table}, $1::jsonb)
854 RETURNING *
855 "#,
856 );
857 Ok((query, Some(serde_json::to_string(&json_obj)?)))
858}
859
860pub fn changed_columns<T: Serialize>(before: &T, after: &T) -> anyhow::Result<Vec<String>> {
865 let (Value::Object(before), Value::Object(after)) =
866 (serde_json::to_value(before)?, serde_json::to_value(after)?)
867 else {
868 return Err(anyhow::anyhow!("Entity must serialize to a JSON object"));
869 };
870 Ok(after
871 .into_iter()
872 .filter(|(k, v)| k != "id" && before.get(k) != Some(v))
873 .map(|(k, _)| k)
874 .collect())
875}
876
877fn and_keyset_predicate(where_clause: &str, keyset_sql: &str) -> String {
881 match where_clause.trim_start().strip_prefix("WHERE ") {
882 None => format!(" WHERE {}", keyset_sql),
883 Some(existing) => format!(" WHERE ({}) AND ({})", existing, keyset_sql),
884 }
885}
886
887fn cast_suffix(data_type: &str, udt_name: &str, udt_schema: &str) -> Option<String> {
888 match data_type {
889 "uuid" => Some("uuid".into()),
890 "numeric" => Some("numeric".into()),
891 "integer" => Some("integer".into()),
892 "smallint" => Some("smallint".into()),
893 "bigint" => Some("bigint".into()),
894 "boolean" => Some("boolean".into()),
895 "date" => Some("date".into()),
896 "timestamp with time zone" => Some("timestamptz".into()),
897 "timestamp without time zone" => Some("timestamp".into()),
898 "USER-DEFINED" if !udt_name.is_empty() => Some(match udt_schema {
902 "" | "public" | "pg_catalog" => udt_name.to_string(),
903 schema => format!("{schema}.{udt_name}"),
904 }),
905 _ => None,
906 }
907}
908
909fn filter_cast_for(type_name: &str, typtype: &str) -> Option<String> {
914 match typtype {
915 "e" => Some(type_name.to_string()),
918 "b" => match type_name {
919 "uuid" | "boolean" | "smallint" | "integer" | "bigint" | "numeric" | "real"
920 | "double precision" | "date" | "interval" | "inet" | "cidr" | "macaddr" => {
921 Some(type_name.to_string())
922 }
923 "time without time zone" => Some("time".into()),
924 "time with time zone" => Some("timetz".into()),
925 "timestamp with time zone" => Some("timestamptz".into()),
926 "timestamp without time zone" => Some("timestamp".into()),
927 _ => None,
928 },
929 _ => None,
930 }
931}
932
933async fn catalog_filter_casts(
940 pool: &PgPool,
941 qualified_table: &str,
942) -> anyhow::Result<HashMap<String, String>> {
943 let q = sqlx::query_as::<Postgres, (String, String, String)>(
944 "SELECT a.attname::text, format_type(a.atttypid, NULL), t.typtype::text
945 FROM pg_catalog.pg_attribute a
946 JOIN pg_catalog.pg_type t ON t.oid = a.atttypid
947 WHERE a.attrelid = to_regclass($1) AND a.attnum > 0 AND NOT a.attisdropped",
948 )
949 .bind(qualified_table.to_string());
950 let rows = crate::company_scope::fetch_all_scoped(pool, q).await?;
951 Ok(rows
952 .into_iter()
953 .filter_map(|(column, type_name, typtype)| {
954 filter_cast_for(&type_name, &typtype).map(|cast| (column, cast))
955 })
956 .collect())
957}
958
959fn merge_filter_casts(
964 hints: &HashMap<String, String>,
965 mut catalog: HashMap<String, String>,
966) -> HashMap<String, String> {
967 for (column, cast) in hints {
968 let qualified_same_type = catalog
969 .get(column)
970 .and_then(|c| c.rsplit_once('.'))
971 .is_some_and(|(_, name)| name.trim_matches('"') == cast.as_str());
972 if !qualified_same_type {
973 catalog.insert(column.clone(), cast.clone());
974 }
975 }
976 catalog
977}
978
979pub(crate) fn explain_unknown_column(error: sqlx::Error, table: &str) -> anyhow::Error {
987 let text = error.to_string();
988 if text.contains("does not exist") && text.contains("column") {
989 return anyhow::Error::new(error).context(format!(
990 "insert into {table} named a column that does not exist: the entity serializes a field \
991 with no matching column. Every serialized field must be a column of the table (rename \
992 it, map it with #[serde(rename)], or skip it with #[serde(skip)])"
993 ));
994 }
995 anyhow::Error::new(error)
996}
997
998fn quote_ident(name: &str) -> String {
1005 format!("\"{}\"", name.replace('"', "\"\""))
1006}
1007
1008#[async_trait]
1009impl<T> DatabaseOperations<T> for PostgresRepository<T>
1010where
1011 T: for<'a> FromRow<'a, PgRow> + Send + Sync + Unpin + Serialize,
1012{
1013 async fn create(&self, entity: &T) -> anyhow::Result<T> {
1014 let (query, json) = insert_statement(&self.table_name, entity)?;
1015 let statement = match json {
1017 Some(json) => sqlx::query_as::<_, T>(&query).bind(json),
1018 None => sqlx::query_as::<_, T>(&query),
1019 };
1020 let result = crate::company_scope::fetch_one_scoped(&self.pool, statement)
1021 .await
1022 .map_err(|e| explain_unknown_column(e, &self.table_name))?;
1023
1024 Ok(result)
1025 }
1026
1027 async fn find_by_id(&self, id: &str) -> anyhow::Result<Option<T>> {
1028 let query = format!("SELECT * FROM {} WHERE id = $1::uuid", self.table_name);
1030 let result = crate::company_scope::fetch_optional_scoped(
1031 &self.pool,
1032 sqlx::query_as::<Postgres, T>(&query).bind(id),
1033 )
1034 .await?;
1035 Ok(result)
1036 }
1037
1038 async fn find_all(&self) -> anyhow::Result<Vec<T>> {
1039 let query = format!("SELECT * FROM {}", self.table_name);
1040 let results = crate::company_scope::fetch_all_scoped(
1041 &self.pool,
1042 sqlx::query_as::<Postgres, T>(&query),
1043 )
1044 .await?;
1045 Ok(results)
1046 }
1047
1048 async fn update(&self, id: &str, entity: &T) -> anyhow::Result<Option<T>> {
1049 self.update_guarded(id, entity, None, "").await
1050 }
1051
1052 async fn delete(&self, id: &str) -> anyhow::Result<bool> {
1053 let query = format!("DELETE FROM {} WHERE id = $1::uuid", self.table_name);
1054 let result = crate::company_scope::execute_scoped(
1055 &self.pool,
1056 sqlx::query(&query).bind(id),
1057 )
1058 .await?;
1059 Ok(result.rows_affected() > 0)
1060 }
1061
1062 async fn count(&self) -> anyhow::Result<u64> {
1063 let query = format!("SELECT COUNT(*) FROM {}", self.table_name);
1064 let count = crate::company_scope::fetch_one_scalar_scoped(
1065 &self.pool,
1066 sqlx::query_scalar::<_, i64>(&query),
1067 )
1068 .await? as u64;
1069 Ok(count)
1070 }
1071
1072 async fn exists(&self, id: &str) -> anyhow::Result<bool> {
1073 let query = format!("SELECT 1 FROM {} WHERE id = $1::uuid LIMIT 1", self.table_name);
1074 let result = crate::company_scope::fetch_optional_scalar_scoped(
1075 &self.pool,
1076 sqlx::query_scalar::<_, i32>(&query).bind(id),
1077 )
1078 .await?;
1079 Ok(result.is_some())
1080 }
1081
1082 async fn execute_query(&self, query: &str) -> anyhow::Result<u64> {
1083 let result = crate::company_scope::execute_scoped(
1084 &self.pool,
1085 sqlx::query(query),
1086 )
1087 .await?;
1088 Ok(result.rows_affected())
1089 }
1090}
1091
1092#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1096pub enum AggregateFn {
1097 Sum,
1098 Avg,
1099 Min,
1100 Max,
1101}
1102
1103impl AggregateFn {
1104 fn sql(self) -> &'static str {
1105 match self {
1106 AggregateFn::Sum => "SUM",
1107 AggregateFn::Avg => "AVG",
1108 AggregateFn::Min => "MIN",
1109 AggregateFn::Max => "MAX",
1110 }
1111 }
1112
1113 fn label(self) -> &'static str {
1114 match self {
1115 AggregateFn::Sum => "sum",
1116 AggregateFn::Avg => "avg",
1117 AggregateFn::Min => "min",
1118 AggregateFn::Max => "max",
1119 }
1120 }
1121
1122 fn requires_numeric(self) -> bool {
1125 matches!(self, AggregateFn::Sum | AggregateFn::Avg)
1126 }
1127}
1128
1129#[derive(Debug, Clone, Default)]
1131pub struct AggregateSpec {
1132 pub group_by: Option<String>,
1134 pub reductions: Vec<(AggregateFn, String)>,
1136 pub group_limit: usize,
1138 pub label_field: Option<String>,
1142 pub label_relation: Option<(String, String)>,
1146}
1147
1148pub const DEFAULT_GROUP_LIMIT: usize = 200;
1156
1157#[derive(Debug, Clone, Serialize, Deserialize)]
1160pub struct AggregateGroup {
1161 pub key: Option<String>,
1162 pub label: Option<String>,
1165 pub count: u64,
1166 pub values: HashMap<String, Option<String>>,
1174}
1175
1176#[derive(Debug, Clone, Serialize, Deserialize)]
1178pub struct AggregateResult {
1179 pub groups: Vec<AggregateGroup>,
1180 pub total: AggregateGroup,
1181 pub truncated: bool,
1183}
1184
1185#[derive(Debug, Clone)]
1188pub struct AggregateFieldError(pub String);
1189
1190impl std::fmt::Display for AggregateFieldError {
1191 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1192 f.write_str(&self.0)
1193 }
1194}
1195
1196impl std::error::Error for AggregateFieldError {}
1197
1198fn is_numeric_pg_type(pg_type: &str) -> bool {
1200 let t = pg_type.trim().to_ascii_lowercase();
1201 let t = t.split('(').next().unwrap_or(&t).trim();
1202 matches!(
1203 t,
1204 "numeric" | "decimal" | "money"
1205 | "smallint" | "int2" | "integer" | "int" | "int4" | "bigint" | "int8"
1206 | "real" | "float4" | "double precision" | "float8"
1207 | "smallserial" | "serial" | "bigserial"
1208 )
1209}
1210
1211async fn catalog_columns(
1220 pool: &PgPool,
1221 qualified_table: &str,
1222) -> anyhow::Result<HashMap<String, String>> {
1223 let (schema, table) = match qualified_table.split_once('.') {
1224 Some((s, t)) => (s.to_string(), t.to_string()),
1225 None => ("public".to_string(), qualified_table.to_string()),
1226 };
1227 let q = sqlx::query_as::<Postgres, (String, String)>(
1228 "SELECT column_name, data_type FROM information_schema.columns
1229 WHERE table_schema = $1 AND table_name = $2",
1230 )
1231 .bind(schema)
1232 .bind(table);
1233 let rows = crate::company_scope::fetch_all_scoped(pool, q).await?;
1234 Ok(rows.into_iter().collect())
1235}
1236
1237fn resolve_column<'a>(
1246 name: &str,
1247 column_types: &'a HashMap<String, String>,
1248) -> Result<(&'a str, &'a str), AggregateFieldError> {
1249 column_types
1250 .get_key_value(name)
1251 .map(|(k, v)| (k.as_str(), v.as_str()))
1252 .ok_or_else(|| {
1253 AggregateFieldError(format!("unknown field `{name}` — not a column of this entity"))
1254 })
1255}
1256
1257impl<T: for<'a> FromRow<'a, PgRow> + Send + Unpin> PostgresRepository<T> {
1258 pub async fn aggregate_with_filters(
1266 &self,
1267 spec: &AggregateSpec,
1268 filters: &HashMap<String, String>,
1269 column_types: &HashMap<String, String>,
1270 search_fields: &[&str],
1271 ) -> anyhow::Result<AggregateResult> {
1272 self.aggregate_with_filters_and_base(spec, None, filters, column_types, search_fields)
1273 .await
1274 }
1275
1276 pub(crate) async fn aggregate_with_filters_and_base(
1280 &self,
1281 spec: &AggregateSpec,
1282 base_condition: Option<&str>,
1283 filters: &HashMap<String, String>,
1284 column_types: &HashMap<String, String>,
1285 search_fields: &[&str],
1286 ) -> anyhow::Result<AggregateResult> {
1287 let mut query_filter = self.parse_typed_filters(filters, column_types, None).await?;
1288 if let Some(cond) = base_condition {
1289 query_filter.add_base_condition(cond);
1290 }
1291 if !search_fields.is_empty() {
1292 query_filter.search_fields = search_fields.iter().map(|s| s.to_string()).collect();
1293 }
1294 query_filter.limit = None;
1297 query_filter.offset = None;
1298 let (where_clause, filter_params) = query_filter.build_where_clause();
1299
1300 let columns = catalog_columns(&self.pool, &self.table_name).await?;
1302 let mut selects: Vec<String> = Vec::new();
1303 let mut value_keys: Vec<String> = Vec::new();
1304 for (func, field) in &spec.reductions {
1305 let (column, pg_type) = resolve_column(field, &columns)?;
1306 if func.requires_numeric() && !is_numeric_pg_type(pg_type) {
1307 return Err(AggregateFieldError(format!(
1308 "cannot {} `{}`: its type is {} — {} needs a numeric column",
1309 func.label(),
1310 column,
1311 pg_type,
1312 func.label()
1313 ))
1314 .into());
1315 }
1316 let key = format!("{}:{}", func.label(), column);
1317 selects.push(format!("{}({})::text AS \"{}\"", func.sql(), column, key));
1320 value_keys.push(key);
1321 }
1322
1323 let group_limit = if spec.group_limit == 0 { DEFAULT_GROUP_LIMIT } else { spec.group_limit };
1324 let reductions = if selects.is_empty() { String::new() } else { format!(", {}", selects.join(", ")) };
1325
1326 let mut label_select = String::new();
1332 let mut label_join = String::new();
1333 if let (Some(field), Some(label_field), Some((rel_table, base_fk))) = (
1334 &spec.group_by,
1335 &spec.label_field,
1336 &spec.label_relation,
1337 ) {
1338 let _ = field;
1339 let qualified = qualify_relation_table(&self.table_name, rel_table);
1340 let rel_columns = catalog_columns(&self.pool, &qualified).await?;
1341 let (label_col, _) = resolve_column(label_field, &rel_columns).map_err(|_| {
1342 AggregateFieldError(format!(
1343 "cannot label groups by `{label_field}`: the related table `{qualified}` has no such column"
1344 ))
1345 })?;
1346 label_select = format!(", (label_rel.{label_col})::text AS __group_label");
1347 label_join = format!(
1348 " LEFT JOIN {qualified} AS label_rel ON label_rel.id IS NOT DISTINCT FROM {base_fk}"
1349 );
1350 }
1351
1352 let sql = match &spec.group_by {
1353 Some(field) => {
1354 let (column, _) = resolve_column(field, &columns)?;
1355 format!(
1356 "SELECT GROUPING({column}) AS __is_total, ({column})::text AS __group_key{label_select}, \
1357 COUNT(*) AS __count{reductions} \
1358 FROM {table}{label_join}{where_clause} \
1359 GROUP BY GROUPING SETS (({column}), ()) \
1360 ORDER BY __is_total DESC, __count DESC \
1361 LIMIT {limit}",
1362 column = column,
1363 label_select = label_select,
1364 label_join = label_join,
1365 reductions = reductions,
1366 table = self.table_name,
1367 where_clause = where_clause,
1368 limit = group_limit + 2,
1371 )
1372 }
1373 None => format!(
1374 "SELECT 1 AS __is_total, NULL::text AS __group_key, COUNT(*) AS __count{reductions} \
1375 FROM {table}{where_clause}",
1376 reductions = reductions,
1377 table = self.table_name,
1378 where_clause = where_clause,
1379 ),
1380 };
1381
1382 let mut builder = sqlx::query(&sql);
1383 for param in &filter_params {
1384 builder = builder.bind(param);
1385 }
1386 let rows = crate::company_scope::fetch_all_rows_scoped(&self.pool, builder).await?;
1387
1388 let read_group = |row: &PgRow| -> AggregateGroup {
1389 use sqlx::Row as _;
1390 let mut values = HashMap::with_capacity(value_keys.len());
1391 for key in &value_keys {
1392 values.insert(key.clone(), row.try_get::<Option<String>, _>(key.as_str()).ok().flatten());
1393 }
1394 AggregateGroup {
1395 key: row.try_get::<Option<String>, _>("__group_key").ok().flatten(),
1396 label: row.try_get::<Option<String>, _>("__group_label").ok().flatten(),
1397 count: row.try_get::<i64, _>("__count").unwrap_or(0).max(0) as u64,
1398 values,
1399 }
1400 };
1401
1402 use sqlx::Row as _;
1403 let mut total: Option<AggregateGroup> = None;
1404 let mut groups: Vec<AggregateGroup> = Vec::new();
1405 for row in &rows {
1406 let is_total = row.try_get::<i32, _>("__is_total").unwrap_or(0) == 1;
1407 if is_total {
1408 total = Some(read_group(row));
1410 } else {
1411 groups.push(read_group(row));
1412 }
1413 }
1414
1415 let truncated = groups.len() > group_limit;
1416 groups.truncate(group_limit);
1417
1418 let total = total.unwrap_or_else(|| AggregateGroup {
1421 key: None,
1422 label: None,
1423 count: 0,
1424 values: value_keys.iter().map(|k| (k.clone(), None)).collect(),
1425 });
1426
1427 Ok(AggregateResult { groups, total, truncated })
1428 }
1429}
1430
1431#[cfg(test)]
1432mod aggregate_field_tests {
1433 use super::*;
1434
1435 fn columns() -> HashMap<String, String> {
1436 [
1437 ("status", "text"),
1438 ("total", "numeric"),
1439 ("qty", "integer"),
1440 ("notes", "text"),
1441 ]
1442 .iter()
1443 .map(|(k, v)| (k.to_string(), v.to_string()))
1444 .collect()
1445 }
1446
1447 #[test]
1448 fn resolves_only_declared_columns() {
1449 let cols = columns();
1450 assert_eq!(resolve_column("total", &cols).unwrap().0, "total");
1451 assert!(resolve_column("password_hash", &cols).is_err());
1452 }
1453
1454 #[test]
1457 fn rejects_injection_attempts_rather_than_escaping_them() {
1458 let cols = columns();
1459 for probe in [
1460 "total) FROM selling.sales_orders; DROP TABLE users --",
1461 "status\"",
1462 "1=1",
1463 "total, (SELECT password FROM users)",
1464 "",
1465 ] {
1466 assert!(
1467 resolve_column(probe, &cols).is_err(),
1468 "`{probe}` must be refused, never escaped into the query"
1469 );
1470 }
1471 }
1472
1473 #[test]
1476 fn returns_the_declared_key_not_the_callers_string() {
1477 let cols = columns();
1478 let (name, _) = resolve_column("total", &cols).unwrap();
1479 assert!(std::ptr::eq(name, cols.get_key_value("total").unwrap().0.as_str()));
1480 }
1481
1482 #[test]
1483 fn sum_and_avg_require_a_numeric_type() {
1484 assert!(AggregateFn::Sum.requires_numeric());
1485 assert!(AggregateFn::Avg.requires_numeric());
1486 assert!(!AggregateFn::Min.requires_numeric());
1488 assert!(!AggregateFn::Max.requires_numeric());
1489 }
1490
1491 #[test]
1492 fn recognises_the_numeric_postgres_types() {
1493 for t in ["numeric", "NUMERIC(14,2)", "integer", "bigint", "double precision", "money"] {
1494 assert!(is_numeric_pg_type(t), "{t} should count as numeric");
1495 }
1496 for t in ["text", "uuid", "timestamptz", "boolean", "jsonb", "USER-DEFINED"] {
1497 assert!(!is_numeric_pg_type(t), "{t} must not accept a SUM");
1498 }
1499 }
1500}
1501
1502#[cfg(test)]
1503mod filter_cast_tests {
1504 use super::*;
1505
1506 #[test]
1507 fn typed_base_columns_get_their_own_cast() {
1508 for (t, want) in [
1509 ("boolean", "boolean"),
1510 ("integer", "integer"),
1511 ("bigint", "bigint"),
1512 ("smallint", "smallint"),
1513 ("numeric", "numeric"),
1514 ("double precision", "double precision"),
1515 ("uuid", "uuid"),
1516 ("date", "date"),
1517 ("time without time zone", "time"),
1518 ("timestamp with time zone", "timestamptz"),
1519 ("timestamp without time zone", "timestamp"),
1520 ] {
1521 assert_eq!(filter_cast_for(t, "b").as_deref(), Some(want), "{t}");
1522 }
1523 }
1524
1525 #[test]
1526 fn text_like_and_composite_columns_keep_the_text_bind() {
1527 for t in ["text", "character varying", "character", "jsonb", "json", "bytea", "text[]", "uuid[]"] {
1528 assert_eq!(filter_cast_for(t, "b"), None, "{t}");
1529 }
1530 assert_eq!(filter_cast_for("approvals.money_amount", "d"), None);
1532 assert_eq!(filter_cast_for("approvals.address", "c"), None);
1533 }
1534
1535 #[test]
1536 fn an_enum_casts_to_its_catalog_name() {
1537 assert_eq!(filter_cast_for("approval_status", "e").as_deref(), Some("approval_status"));
1538 assert_eq!(
1539 filter_cast_for("recruitment.stage_kind", "e").as_deref(),
1540 Some("recruitment.stage_kind")
1541 );
1542 }
1543
1544 #[test]
1545 fn a_generated_hint_wins_over_the_catalog_for_its_column() {
1546 let hints: HashMap<String, String> =
1547 [("id", "uuid"), ("status", "approval_status")].iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
1548 let catalog: HashMap<String, String> = [
1549 ("id", "uuid"),
1550 ("status", "approvals.approval_status"),
1551 ("folded", "boolean"),
1552 ("requested_by", "uuid"),
1553 ]
1554 .iter()
1555 .map(|(k, v)| (k.to_string(), v.to_string()))
1556 .collect();
1557 let merged = merge_filter_casts(&hints, catalog);
1558 assert_eq!(merged["status"], "approvals.approval_status");
1561 assert_eq!(merged["id"], "uuid");
1562 assert_eq!(merged["folded"], "boolean");
1563 assert_eq!(merged["requested_by"], "uuid");
1564 assert_eq!(merged.len(), 4);
1565 }
1566
1567 #[test]
1568 fn a_hint_for_another_type_still_decides_its_column() {
1569 let hints: HashMap<String, String> =
1570 [("amount", "numeric")].iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
1571 let catalog: HashMap<String, String> =
1572 [("amount", "real")].iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
1573 assert_eq!(merge_filter_casts(&hints, catalog)["amount"], "numeric");
1574 }
1575
1576 #[test]
1577 fn a_filter_on_a_bare_enum_hint_reads_the_catalog_to_qualify_it() {
1578 let hints: HashMap<String, String> = [("id", "uuid"), ("status", "task_status")]
1579 .iter()
1580 .map(|(k, v)| (k.to_string(), v.to_string()))
1581 .collect();
1582 let needs = |pairs: &[(&str, &str)]| {
1583 let f: HashMap<String, String> =
1584 pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
1585 parse_query_filter(&f, &hints, None).unwrap().needs_catalog_types()
1586 };
1587 assert!(needs(&[("status[in]", "done,skipped")]));
1588 assert!(needs(&[("status[eq]", "done")]));
1589 assert!(!needs(&[("id[in]", "a,b")]));
1591 }
1592
1593 #[test]
1594 fn a_sort_on_an_enum_casts_to_its_schema_qualified_name() {
1595 assert_eq!(
1596 cast_suffix("USER-DEFINED", "task_status", "lifecycle").as_deref(),
1597 Some("lifecycle.task_status")
1598 );
1599 assert_eq!(cast_suffix("USER-DEFINED", "mood", "public").as_deref(), Some("mood"));
1600 assert_eq!(cast_suffix("uuid", "uuid", "pg_catalog").as_deref(), Some("uuid"));
1601 }
1602
1603 #[test]
1604 fn only_a_filter_with_an_uncast_comparison_reads_the_catalog() {
1605 let hints: HashMap<String, String> =
1606 [("id", "uuid")].iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
1607 let parse = |pairs: &[(&str, &str)]| {
1608 let f: HashMap<String, String> =
1609 pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
1610 parse_query_filter(&f, &hints, None).unwrap().has_uncast_value_conditions()
1611 };
1612 assert!(!parse(&[("id[in]", "a,b"), ("name[contain]", "x"), ("limit", "5")]));
1613 assert!(!parse(&[("deleted_by[isnull]", "1")]));
1614 assert!(parse(&[("folded[eq]", "false")]));
1615 assert!(parse(&[("folded", "false")]));
1616 assert!(parse(&[("scheduled_at[between]", "2026-10-01,2026-10-03")]));
1617 assert!(parse(&[("sequence[or]", "3")]));
1618 }
1619}
1620
1621#[cfg(test)]
1622mod keyset_predicate_tests {
1623 use super::and_keyset_predicate;
1624
1625 #[test]
1626 fn a_client_or_cannot_escape_the_keyset_window() {
1627 assert_eq!(
1628 and_keyset_predicate(" WHERE kind = $1 OR status = $2", "id > $3"),
1629 " WHERE (kind = $1 OR status = $2) AND (id > $3)"
1630 );
1631 }
1632
1633 #[test]
1634 fn no_filter_leaves_the_keyset_alone() {
1635 assert_eq!(and_keyset_predicate("", "id > $1"), " WHERE id > $1");
1636 }
1637}
1638
1639#[cfg(test)]
1640mod changed_columns_tests {
1641 use super::changed_columns;
1642
1643 #[derive(serde::Serialize)]
1644 struct Row {
1645 id: &'static str,
1646 notes: &'static str,
1647 status: &'static str,
1648 #[serde(skip_serializing_if = "Option::is_none")]
1649 label: Option<&'static str>,
1650 }
1651
1652 #[test]
1653 fn only_the_fields_that_differ_are_changed() {
1654 let before = Row {
1655 id: "1",
1656 notes: "a",
1657 status: "draft",
1658 label: None,
1659 };
1660 let after = Row {
1661 id: "2",
1662 notes: "b",
1663 status: "draft",
1664 label: Some("x"),
1665 };
1666 assert_eq!(
1667 changed_columns(&before, &after).unwrap(),
1668 vec!["label".to_string(), "notes".to_string()]
1669 );
1670 assert!(changed_columns(&before, &before).unwrap().is_empty());
1671 }
1672}