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 fn new(page: u32, per_page: u32) -> Self {
42 Self {
43 page: page.max(1),
44 per_page: per_page.clamp(1, 100), }
46 }
47
48 pub fn offset(&self) -> u32 {
49 (self.page - 1) * self.per_page
50 }
51
52 pub fn limit(&self) -> u32 {
53 self.per_page
54 }
55}
56
57#[derive(Debug, Clone, Default)]
59pub struct SortParams {
60 pub field: String,
61 pub direction: SortDirection,
62}
63
64#[derive(Debug, Clone, Default)]
65pub enum SortDirection {
66 #[default]
67 Asc,
68 Desc,
69}
70
71#[derive(Debug, Clone, Default)]
73pub struct FilterParams {
74 pub conditions: HashMap<String, FilterCondition>,
75}
76
77#[derive(Debug, Clone)]
78pub enum FilterCondition {
79 Equals(String),
80 NotEquals(String),
81 GreaterThan(String),
82 LessThan(String),
83 Like(String),
84 In(Vec<String>),
85 IsNull,
86 IsNotNull,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct PaginatedResult<T> {
92 pub data: Vec<T>,
93 pub pagination: PaginationInfo,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct PaginationInfo {
98 pub page: u32,
99 pub per_page: u32,
100 pub total: u64,
101 pub total_pages: u32,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub next_cursor: Option<String>,
106 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub prev_cursor: Option<String>,
110 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub has_more: Option<bool>,
114 #[serde(default = "default_count_mode")]
118 pub count_mode: String,
119}
120
121pub const EXACT_COUNT_BELOW: u64 = 10_000;
124
125fn default_count_mode() -> String {
126 "exact".to_string()
127}
128
129impl PaginationInfo {
130 pub fn new(page: u32, per_page: u32, total: u64) -> Self {
131 let total_pages = ((total as f64) / (per_page as f64)).ceil() as u32;
132 Self {
133 page,
134 per_page,
135 total,
136 total_pages,
137 next_cursor: None,
138 prev_cursor: None,
139 has_more: None,
140 count_mode: default_count_mode(),
141 }
142 }
143}
144
145#[async_trait]
147pub trait DatabaseOperations<T: for<'a> FromRow<'a, PgRow> + Send + Unpin> {
148 async fn create(&self, entity: &T) -> anyhow::Result<T>;
150
151 async fn find_by_id(&self, id: &str) -> anyhow::Result<Option<T>>;
153
154 async fn find_all(&self) -> anyhow::Result<Vec<T>>;
156
157 async fn update(&self, id: &str, entity: &T) -> anyhow::Result<Option<T>>;
159
160 async fn delete(&self, id: &str) -> anyhow::Result<bool>;
162
163 async fn count(&self) -> anyhow::Result<u64>;
165
166 async fn exists(&self, id: &str) -> anyhow::Result<bool>;
168
169 async fn execute_query(&self, query: &str) -> anyhow::Result<u64>;
171}
172
173pub struct PostgresRepository<T: for<'a> FromRow<'a, PgRow> + Send + Unpin> {
175 pool: PgPool,
176 table_name: String,
177 _phantom: std::marker::PhantomData<T>,
178}
179
180impl<T: for<'a> FromRow<'a, PgRow> + Send + Unpin> PostgresRepository<T> {
181 pub fn new(pool: PgPool, table_name: &str) -> Self {
182 Self {
183 pool,
184 table_name: table_name.to_string(),
185 _phantom: std::marker::PhantomData,
186 }
187 }
188
189 pub fn pool(&self) -> &PgPool {
190 &self.pool
191 }
192
193 pub fn table_name(&self) -> &str {
194 &self.table_name
195 }
196
197 pub async fn list_with_filters(
253 &self,
254 pagination: PaginationParams,
255 filters: &HashMap<String, String>,
256 column_types: &HashMap<String, String>,
257 search_fields: &[&str],
258 ) -> anyhow::Result<PaginatedResult<T>>
259 where
260 T: Send + Sync,
261 {
262 self.list_with_filters_and_base(pagination, None, filters, column_types, search_fields)
263 .await
264 }
265
266 pub(crate) async fn list_with_filters_and_base(
273 &self,
274 pagination: PaginationParams,
275 base_condition: Option<&str>,
276 filters: &HashMap<String, String>,
277 column_types: &HashMap<String, String>,
278 search_fields: &[&str],
279 ) -> anyhow::Result<PaginatedResult<T>>
280 where
281 T: Send + Sync,
282 {
283 let mut query_filter = self.parse_typed_filters(filters, column_types, None).await?;
285 if let Some(cond) = base_condition {
286 query_filter.add_base_condition(cond);
287 }
288
289 if !search_fields.is_empty() {
291 query_filter.search_fields = search_fields.iter().map(|s| s.to_string()).collect();
292 }
293
294 self.execute_list(pagination, query_filter).await
295 }
296
297 pub async fn list_with_filters_whitelisted(
329 &self,
330 pagination: PaginationParams,
331 filters: &HashMap<String, String>,
332 column_types: &HashMap<String, String>,
333 search_fields: &[&str],
334 allowed_fields: Option<&HashSet<String>>,
335 ) -> anyhow::Result<PaginatedResult<T>>
336 where
337 T: Send + Sync,
338 {
339 let mut query_filter =
341 self.parse_typed_filters(filters, column_types, allowed_fields).await?;
342
343 if !search_fields.is_empty() {
345 query_filter.search_fields = search_fields.iter().map(|s| s.to_string()).collect();
346 }
347
348 self.execute_list(pagination, query_filter).await
349 }
350
351 pub async fn update_guarded(
363 &self,
364 id: &str,
365 entity: &T,
366 only: Option<&[String]>,
367 guard: &str,
368 ) -> anyhow::Result<Option<T>>
369 where
370 T: Serialize + Send + Sync,
371 {
372 let result = match update_statement(&self.table_name, entity, only, guard)? {
373 UpdateStatement::Unchanged(query) => {
374 crate::company_scope::fetch_optional_scoped(
375 &self.pool,
376 sqlx::query_as::<_, T>(&query).bind(id),
377 )
378 .await?
379 }
380 UpdateStatement::Update { query, json } => {
381 crate::company_scope::fetch_optional_scoped(
382 &self.pool,
383 sqlx::query_as::<_, T>(&query).bind(json).bind(id),
384 )
385 .await?
386 }
387 };
388
389 Ok(result)
390 }
391
392 async fn parse_typed_filters(
407 &self,
408 filters: &HashMap<String, String>,
409 column_types: &HashMap<String, String>,
410 allowed_fields: Option<&HashSet<String>>,
411 ) -> anyhow::Result<crate::QueryFilter> {
412 let query_filter = parse_query_filter(filters, column_types, allowed_fields)?;
413 if !query_filter.needs_catalog_types() {
414 return Ok(query_filter);
415 }
416 let catalog = catalog_filter_casts(&self.pool, &self.table_name).await?;
417 let merged = merge_filter_casts(column_types, catalog);
418 parse_query_filter(filters, &merged, allowed_fields)
419 }
420
421 #[allow(clippy::type_complexity)]
435 async fn execute_list(
436 &self,
437 pagination: PaginationParams,
438 query_filter: crate::QueryFilter,
439 ) -> anyhow::Result<PaginatedResult<T>> {
440 let limit = pagination.limit() as i64;
441 let backwards =
442 query_filter.cursor_before.is_some() && query_filter.cursor_after.is_none();
443 let cursor_walk = query_filter.cursor_after.is_some() || backwards;
444
445 let (mut where_clause, mut filter_params) = query_filter.build_where_clause();
446 let order_clause;
447 let mut boundary_casts: Vec<Option<String>> = Vec::new();
449
450 let mut sorts: Vec<(String, FilterSortDirection)> = query_filter
455 .sorts
456 .iter()
457 .map(|s| (s.field.clone(), s.direction.clone()))
458 .collect();
459 if cursor_walk || !sorts.is_empty() {
460 if sorts.is_empty() {
461 sorts.push(("id".into(), FilterSortDirection::Asc));
462 } else if sorts.last().map(|(f, _)| f != "id").unwrap_or(true) {
463 sorts.push(("id".into(), FilterSortDirection::Asc));
464 }
465 }
466
467 if !sorts.is_empty() {
470 boundary_casts = self.sort_column_casts(&sorts).await?;
471 }
472
473 if cursor_walk {
474 let opaque = if backwards {
475 query_filter.cursor_before.clone().unwrap()
476 } else {
477 query_filter.cursor_after.clone().unwrap()
478 };
479 let payload = crate::filter::cursor::decode_cursor(&opaque, &sorts)
480 .map_err(|e| anyhow::anyhow!("cursor refused: {e}"))?;
481 let mut idx = filter_params.len() + 1;
482 let (keyset_sql, keyset_params) = crate::filter::cursor::build_keyset_predicate(
483 &payload,
484 &mut idx,
485 &boundary_casts,
486 backwards,
487 );
488 where_clause = and_keyset_predicate(&where_clause, &keyset_sql);
489 filter_params.extend(keyset_params);
490 let parts: Vec<String> = sorts
491 .iter()
492 .map(|(f, d)| {
493 let dir = if (*d == FilterSortDirection::Desc) != backwards {
494 "DESC"
495 } else {
496 "ASC"
497 };
498 format!("{} {}", f, dir)
499 })
500 .collect();
501 order_clause = format!(" ORDER BY {}", parts.join(", "));
502 } else {
503 if sorts.is_empty() {
506 order_clause = query_filter.build_order_by_clause();
507 } else {
508 let parts: Vec<String> = sorts
509 .iter()
510 .map(|(f, d)| {
511 let dir =
512 if *d == FilterSortDirection::Desc { "DESC" } else { "ASC" };
513 format!("{} {}", f, dir)
514 })
515 .collect();
516 order_clause = format!(" ORDER BY {}", parts.join(", "));
517 }
518 }
519 let boundary_sorts: Vec<(String, FilterSortDirection)> = sorts;
521
522 let (total, count_mode) = if query_filter.estimate_total {
528 let estimate = self.estimate_filtered_rows(&where_clause, &filter_params).await?;
529 if estimate <= EXACT_COUNT_BELOW {
530 (self.count_filtered_rows(&where_clause, &filter_params).await?, "exact")
531 } else {
532 (estimate, "estimate")
533 }
534 } else if cursor_walk {
535 (0u64, "none")
536 } else {
537 (self.count_filtered_rows(&where_clause, &filter_params).await?, "exact")
538 };
539
540 let fetch = limit + 1;
543 let data_query = if cursor_walk {
544 format!(
545 "SELECT * FROM {}{}{} LIMIT {}",
546 self.table_name, where_clause, order_clause, fetch
547 )
548 } else {
549 format!(
550 "SELECT * FROM {}{}{} LIMIT {} OFFSET {}",
551 self.table_name,
552 where_clause,
553 order_clause,
554 fetch,
555 pagination.offset()
556 )
557 };
558
559 let mut pagination_info = PaginationInfo::new(pagination.page, pagination.per_page, total);
560 pagination_info.count_mode = count_mode.to_string();
561
562 let mut rows_query = sqlx::query(&data_query);
567 for param in &filter_params {
568 rows_query = rows_query.bind(param);
569 }
570 let rows: Vec<PgRow> =
571 crate::company_scope::fetch_all_rows_scoped(&self.pool, rows_query).await?;
572 let has_more = rows.len() as i64 > limit;
573 let mut page: Vec<PgRow> = rows.into_iter().take(limit as usize).collect();
574 if backwards {
575 page.reverse();
576 }
577 let data: anyhow::Result<Vec<T>> = page
578 .iter()
579 .map(|row| T::from_row(row).map_err(|e| anyhow::anyhow!("decode row: {e}")))
580 .collect();
581 let data = data?;
582
583 let deterministic = !boundary_sorts.is_empty();
587 let next_cursor = if (has_more || backwards) && deterministic {
588 page.last().and_then(|r| {
589 let casts = if boundary_casts.is_empty() {
590 &boundary_casts
593 } else {
594 &boundary_casts
595 };
596 self.row_cursor(r, &boundary_sorts, casts)
597 })
598 } else {
599 None
600 };
601 let prev_cursor = if !page.is_empty() && deterministic {
602 page.first().and_then(|r| self.row_cursor(r, &boundary_sorts, &boundary_casts))
603 } else {
604 None
605 };
606
607 pagination_info.has_more = Some(has_more);
608 pagination_info.next_cursor = next_cursor;
609 pagination_info.prev_cursor = prev_cursor;
610
611 Ok(PaginatedResult {
612 data,
613 pagination: pagination_info,
614 })
615 }
616
617 async fn count_filtered_rows(
622 &self,
623 where_clause: &str,
624 filter_params: &[String],
625 ) -> anyhow::Result<u64> {
626 let count_query = format!("SELECT COUNT(*) FROM {}{}", self.table_name, where_clause);
627 let mut count_query_builder = sqlx::query_scalar::<_, i64>(&count_query);
628 for param in filter_params {
629 count_query_builder = count_query_builder.bind(param);
630 }
631 Ok(crate::company_scope::fetch_one_scalar_scoped(&self.pool, count_query_builder).await?
632 as u64)
633 }
634
635 async fn estimate_filtered_rows(
636 &self,
637 where_clause: &str,
638 filter_params: &[String],
639 ) -> anyhow::Result<u64> {
640 let explain = format!("EXPLAIN (FORMAT JSON) SELECT 1 FROM {}{}", self.table_name, where_clause);
641 let mut builder = sqlx::query_scalar::<_, serde_json::Value>(&explain);
642 for param in filter_params {
643 builder = builder.bind(param);
644 }
645 let plan: serde_json::Value =
646 crate::company_scope::fetch_one_scalar_scoped(&self.pool, builder).await?;
647 let rows = plan
648 .as_array()
649 .and_then(|a| a.first())
650 .and_then(|top| top.get("Plan"))
651 .and_then(|p| p.get("Plan Rows"))
652 .and_then(|r| r.as_i64())
653 .unwrap_or(0);
654 Ok(rows.max(0) as u64)
655 }
656
657 async fn sort_column_casts(
662 &self,
663 sorts: &[(String, FilterSortDirection)],
664 ) -> anyhow::Result<Vec<Option<String>>> {
665 let (schema, table) = match self.table_name.rsplit_once('.') {
666 Some((s, t)) => (s.to_string(), t.to_string()),
667 None => ("public".to_string(), self.table_name.clone()),
668 };
669 let mut casts: Vec<Option<String>> = Vec::with_capacity(sorts.len());
670 for (field, _) in sorts {
671 let row: Option<(String, String, String)> = sqlx::query_as(
672 "SELECT data_type, coalesce(udt_name, ''), coalesce(udt_schema, '') \
673 FROM information_schema.columns \
674 WHERE table_schema = $1 AND table_name = $2 AND column_name = $3",
675 )
676 .bind(&schema)
677 .bind(&table)
678 .bind(field)
679 .fetch_optional(&self.pool)
680 .await?;
681 let cast = row
682 .map(|(data_type, udt, udt_schema)| cast_suffix(&data_type, &udt, &udt_schema))
683 .flatten();
684 casts.push(cast);
685 }
686 Ok(casts)
687 }
688
689 fn row_cursor(
693 &self,
694 row: &PgRow,
695 sorts: &[(String, FilterSortDirection)],
696 casts: &[Option<String>],
697 ) -> Option<String> {
698 let id: uuid::Uuid = row.try_get("id").ok()?;
699 let mut values: Vec<serde_json::Value> = Vec::with_capacity(sorts.len());
700 for (i, (field, _)) in sorts.iter().enumerate() {
701 let field = field.as_str();
702 let mut data_type = casts.get(i).and_then(|c| c.as_deref()).unwrap_or("");
703 if field == "id" && data_type.is_empty() {
707 data_type = "uuid";
708 }
709 let text: Option<String> = match data_type {
710 "numeric" => row
711 .try_get::<Option<sqlx::types::Decimal>, _>(field)
712 .ok()?
713 .map(|d| d.to_string()),
714 "uuid" => row
715 .try_get::<Option<uuid::Uuid>, _>(field)
716 .ok()?
717 .map(|u| u.to_string()),
718 "timestamptz" => row
719 .try_get::<Option<chrono::DateTime<chrono::Utc>>, _>(field)
720 .ok()?
721 .map(|t| t.to_rfc3339()),
722 "integer" | "smallint" => row
723 .try_get::<Option<i32>, _>(field)
724 .ok()?
725 .map(|n| n.to_string()),
726 "bigint" => row
727 .try_get::<Option<i64>, _>(field)
728 .ok()?
729 .map(|n| n.to_string()),
730 "boolean" => row
731 .try_get::<Option<bool>, _>(field)
732 .ok()?
733 .map(|b| b.to_string()),
734 "date" => row
735 .try_get::<Option<chrono::NaiveDate>, _>(field)
736 .ok()?
737 .map(|d| d.to_string()),
738 _ => row
739 .try_get::<Option<String>, _>(field)
740 .ok()?
741 .filter(|s| !s.is_empty() || data_type.is_empty()),
742 };
743 values.push(serde_json::Value::String(text?));
744 }
745 crate::filter::cursor::encode_cursor(sorts, &values, &id.to_string()).ok()
746 }
747}
748
749pub(crate) enum UpdateStatement {
753 Unchanged(String),
755 Update { query: String, json: String },
757}
758
759pub(crate) fn update_statement<T: Serialize>(
761 table: &str,
762 entity: &T,
763 only: Option<&[String]>,
764 guard: &str,
765) -> anyhow::Result<UpdateStatement> {
766 let json_obj = match serde_json::to_value(entity)? {
767 Value::Object(obj) => obj,
768 _ => return Err(anyhow::anyhow!("Entity must serialize to a JSON object")),
769 };
770
771 let update_columns: Vec<&String> = json_obj
772 .keys()
773 .filter(|k| *k != "id")
774 .filter(|k| only.is_none_or(|cols| cols.iter().any(|c| c == *k)))
775 .collect();
776
777 if update_columns.is_empty() {
778 return Ok(UpdateStatement::Unchanged(format!(
779 "SELECT t.* FROM {table} AS t WHERE t.id = $1::uuid{guard}"
780 )));
781 }
782
783 let column_names = update_columns
784 .iter()
785 .map(|k| quote_ident(k))
786 .collect::<Vec<_>>()
787 .join(", ");
788 let json = serde_json::to_string(&json_obj)?;
789
790 let query = format!(
793 r#"
794 WITH new_row AS (
795 SELECT (jsonb_populate_record(NULL::{table}, $1::jsonb)).*
796 )
797 UPDATE {table} AS t
798 SET ({columns}) = (SELECT {columns} FROM new_row)
799 WHERE t.id = $2::uuid{guard}
800 RETURNING t.*
801 "#,
802 table = table,
803 columns = column_names,
804 guard = guard,
805 );
806 Ok(UpdateStatement::Update { query, json })
807}
808
809pub(crate) fn insert_statement<T: Serialize>(
812 table: &str,
813 entity: &T,
814) -> anyhow::Result<(String, Option<String>)> {
815 let json_obj = match serde_json::to_value(entity)? {
816 Value::Object(obj) => obj,
817 _ => return Err(anyhow::anyhow!("Entity must serialize to a JSON object")),
818 };
819
820 let insert_columns: Vec<String> = json_obj.keys().map(|k| quote_ident(k)).collect();
834
835 if insert_columns.is_empty() {
836 return Ok((format!("INSERT INTO {table} DEFAULT VALUES RETURNING *"), None));
839 }
840 let columns = insert_columns.join(", ");
841 let query = format!(
842 r#"
843 INSERT INTO {table} ({columns})
844 SELECT {columns} FROM jsonb_populate_record(NULL::{table}, $1::jsonb)
845 RETURNING *
846 "#,
847 );
848 Ok((query, Some(serde_json::to_string(&json_obj)?)))
849}
850
851pub fn changed_columns<T: Serialize>(before: &T, after: &T) -> anyhow::Result<Vec<String>> {
856 let (Value::Object(before), Value::Object(after)) =
857 (serde_json::to_value(before)?, serde_json::to_value(after)?)
858 else {
859 return Err(anyhow::anyhow!("Entity must serialize to a JSON object"));
860 };
861 Ok(after
862 .into_iter()
863 .filter(|(k, v)| k != "id" && before.get(k) != Some(v))
864 .map(|(k, _)| k)
865 .collect())
866}
867
868fn and_keyset_predicate(where_clause: &str, keyset_sql: &str) -> String {
872 match where_clause.trim_start().strip_prefix("WHERE ") {
873 None => format!(" WHERE {}", keyset_sql),
874 Some(existing) => format!(" WHERE ({}) AND ({})", existing, keyset_sql),
875 }
876}
877
878fn cast_suffix(data_type: &str, udt_name: &str, udt_schema: &str) -> Option<String> {
879 match data_type {
880 "uuid" => Some("uuid".into()),
881 "numeric" => Some("numeric".into()),
882 "integer" => Some("integer".into()),
883 "smallint" => Some("smallint".into()),
884 "bigint" => Some("bigint".into()),
885 "boolean" => Some("boolean".into()),
886 "date" => Some("date".into()),
887 "timestamp with time zone" => Some("timestamptz".into()),
888 "timestamp without time zone" => Some("timestamp".into()),
889 "USER-DEFINED" if !udt_name.is_empty() => Some(match udt_schema {
893 "" | "public" | "pg_catalog" => udt_name.to_string(),
894 schema => format!("{schema}.{udt_name}"),
895 }),
896 _ => None,
897 }
898}
899
900fn filter_cast_for(type_name: &str, typtype: &str) -> Option<String> {
905 match typtype {
906 "e" => Some(type_name.to_string()),
909 "b" => match type_name {
910 "uuid" | "boolean" | "smallint" | "integer" | "bigint" | "numeric" | "real"
911 | "double precision" | "date" | "interval" | "inet" | "cidr" | "macaddr" => {
912 Some(type_name.to_string())
913 }
914 "time without time zone" => Some("time".into()),
915 "time with time zone" => Some("timetz".into()),
916 "timestamp with time zone" => Some("timestamptz".into()),
917 "timestamp without time zone" => Some("timestamp".into()),
918 _ => None,
919 },
920 _ => None,
921 }
922}
923
924async fn catalog_filter_casts(
931 pool: &PgPool,
932 qualified_table: &str,
933) -> anyhow::Result<HashMap<String, String>> {
934 let q = sqlx::query_as::<Postgres, (String, String, String)>(
935 "SELECT a.attname::text, format_type(a.atttypid, NULL), t.typtype::text
936 FROM pg_catalog.pg_attribute a
937 JOIN pg_catalog.pg_type t ON t.oid = a.atttypid
938 WHERE a.attrelid = to_regclass($1) AND a.attnum > 0 AND NOT a.attisdropped",
939 )
940 .bind(qualified_table.to_string());
941 let rows = crate::company_scope::fetch_all_scoped(pool, q).await?;
942 Ok(rows
943 .into_iter()
944 .filter_map(|(column, type_name, typtype)| {
945 filter_cast_for(&type_name, &typtype).map(|cast| (column, cast))
946 })
947 .collect())
948}
949
950fn merge_filter_casts(
955 hints: &HashMap<String, String>,
956 mut catalog: HashMap<String, String>,
957) -> HashMap<String, String> {
958 for (column, cast) in hints {
959 let qualified_same_type = catalog
960 .get(column)
961 .and_then(|c| c.rsplit_once('.'))
962 .is_some_and(|(_, name)| name.trim_matches('"') == cast.as_str());
963 if !qualified_same_type {
964 catalog.insert(column.clone(), cast.clone());
965 }
966 }
967 catalog
968}
969
970pub(crate) fn explain_unknown_column(error: sqlx::Error, table: &str) -> anyhow::Error {
978 let text = error.to_string();
979 if text.contains("does not exist") && text.contains("column") {
980 return anyhow::Error::new(error).context(format!(
981 "insert into {table} named a column that does not exist: the entity serializes a field \
982 with no matching column. Every serialized field must be a column of the table (rename \
983 it, map it with #[serde(rename)], or skip it with #[serde(skip)])"
984 ));
985 }
986 anyhow::Error::new(error)
987}
988
989fn quote_ident(name: &str) -> String {
996 format!("\"{}\"", name.replace('"', "\"\""))
997}
998
999#[async_trait]
1000impl<T> DatabaseOperations<T> for PostgresRepository<T>
1001where
1002 T: for<'a> FromRow<'a, PgRow> + Send + Sync + Unpin + Serialize,
1003{
1004 async fn create(&self, entity: &T) -> anyhow::Result<T> {
1005 let (query, json) = insert_statement(&self.table_name, entity)?;
1006 let statement = match json {
1008 Some(json) => sqlx::query_as::<_, T>(&query).bind(json),
1009 None => sqlx::query_as::<_, T>(&query),
1010 };
1011 let result = crate::company_scope::fetch_one_scoped(&self.pool, statement)
1012 .await
1013 .map_err(|e| explain_unknown_column(e, &self.table_name))?;
1014
1015 Ok(result)
1016 }
1017
1018 async fn find_by_id(&self, id: &str) -> anyhow::Result<Option<T>> {
1019 let query = format!("SELECT * FROM {} WHERE id = $1::uuid", self.table_name);
1021 let result = crate::company_scope::fetch_optional_scoped(
1022 &self.pool,
1023 sqlx::query_as::<Postgres, T>(&query).bind(id),
1024 )
1025 .await?;
1026 Ok(result)
1027 }
1028
1029 async fn find_all(&self) -> anyhow::Result<Vec<T>> {
1030 let query = format!("SELECT * FROM {}", self.table_name);
1031 let results = crate::company_scope::fetch_all_scoped(
1032 &self.pool,
1033 sqlx::query_as::<Postgres, T>(&query),
1034 )
1035 .await?;
1036 Ok(results)
1037 }
1038
1039 async fn update(&self, id: &str, entity: &T) -> anyhow::Result<Option<T>> {
1040 self.update_guarded(id, entity, None, "").await
1041 }
1042
1043 async fn delete(&self, id: &str) -> anyhow::Result<bool> {
1044 let query = format!("DELETE FROM {} WHERE id = $1::uuid", self.table_name);
1045 let result = crate::company_scope::execute_scoped(
1046 &self.pool,
1047 sqlx::query(&query).bind(id),
1048 )
1049 .await?;
1050 Ok(result.rows_affected() > 0)
1051 }
1052
1053 async fn count(&self) -> anyhow::Result<u64> {
1054 let query = format!("SELECT COUNT(*) FROM {}", self.table_name);
1055 let count = crate::company_scope::fetch_one_scalar_scoped(
1056 &self.pool,
1057 sqlx::query_scalar::<_, i64>(&query),
1058 )
1059 .await? as u64;
1060 Ok(count)
1061 }
1062
1063 async fn exists(&self, id: &str) -> anyhow::Result<bool> {
1064 let query = format!("SELECT 1 FROM {} WHERE id = $1::uuid LIMIT 1", self.table_name);
1065 let result = crate::company_scope::fetch_optional_scalar_scoped(
1066 &self.pool,
1067 sqlx::query_scalar::<_, i32>(&query).bind(id),
1068 )
1069 .await?;
1070 Ok(result.is_some())
1071 }
1072
1073 async fn execute_query(&self, query: &str) -> anyhow::Result<u64> {
1074 let result = crate::company_scope::execute_scoped(
1075 &self.pool,
1076 sqlx::query(query),
1077 )
1078 .await?;
1079 Ok(result.rows_affected())
1080 }
1081}
1082
1083#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1087pub enum AggregateFn {
1088 Sum,
1089 Avg,
1090 Min,
1091 Max,
1092}
1093
1094impl AggregateFn {
1095 fn sql(self) -> &'static str {
1096 match self {
1097 AggregateFn::Sum => "SUM",
1098 AggregateFn::Avg => "AVG",
1099 AggregateFn::Min => "MIN",
1100 AggregateFn::Max => "MAX",
1101 }
1102 }
1103
1104 fn label(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 requires_numeric(self) -> bool {
1116 matches!(self, AggregateFn::Sum | AggregateFn::Avg)
1117 }
1118}
1119
1120#[derive(Debug, Clone, Default)]
1122pub struct AggregateSpec {
1123 pub group_by: Option<String>,
1125 pub reductions: Vec<(AggregateFn, String)>,
1127 pub group_limit: usize,
1129 pub label_field: Option<String>,
1133 pub label_relation: Option<(String, String)>,
1137}
1138
1139pub const DEFAULT_GROUP_LIMIT: usize = 200;
1147
1148#[derive(Debug, Clone, Serialize, Deserialize)]
1151pub struct AggregateGroup {
1152 pub key: Option<String>,
1153 pub label: Option<String>,
1156 pub count: u64,
1157 pub values: HashMap<String, Option<String>>,
1165}
1166
1167#[derive(Debug, Clone, Serialize, Deserialize)]
1169pub struct AggregateResult {
1170 pub groups: Vec<AggregateGroup>,
1171 pub total: AggregateGroup,
1172 pub truncated: bool,
1174}
1175
1176#[derive(Debug, Clone)]
1179pub struct AggregateFieldError(pub String);
1180
1181impl std::fmt::Display for AggregateFieldError {
1182 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1183 f.write_str(&self.0)
1184 }
1185}
1186
1187impl std::error::Error for AggregateFieldError {}
1188
1189fn is_numeric_pg_type(pg_type: &str) -> bool {
1191 let t = pg_type.trim().to_ascii_lowercase();
1192 let t = t.split('(').next().unwrap_or(&t).trim();
1193 matches!(
1194 t,
1195 "numeric" | "decimal" | "money"
1196 | "smallint" | "int2" | "integer" | "int" | "int4" | "bigint" | "int8"
1197 | "real" | "float4" | "double precision" | "float8"
1198 | "smallserial" | "serial" | "bigserial"
1199 )
1200}
1201
1202async fn catalog_columns(
1211 pool: &PgPool,
1212 qualified_table: &str,
1213) -> anyhow::Result<HashMap<String, String>> {
1214 let (schema, table) = match qualified_table.split_once('.') {
1215 Some((s, t)) => (s.to_string(), t.to_string()),
1216 None => ("public".to_string(), qualified_table.to_string()),
1217 };
1218 let q = sqlx::query_as::<Postgres, (String, String)>(
1219 "SELECT column_name, data_type FROM information_schema.columns
1220 WHERE table_schema = $1 AND table_name = $2",
1221 )
1222 .bind(schema)
1223 .bind(table);
1224 let rows = crate::company_scope::fetch_all_scoped(pool, q).await?;
1225 Ok(rows.into_iter().collect())
1226}
1227
1228fn resolve_column<'a>(
1237 name: &str,
1238 column_types: &'a HashMap<String, String>,
1239) -> Result<(&'a str, &'a str), AggregateFieldError> {
1240 column_types
1241 .get_key_value(name)
1242 .map(|(k, v)| (k.as_str(), v.as_str()))
1243 .ok_or_else(|| {
1244 AggregateFieldError(format!("unknown field `{name}` — not a column of this entity"))
1245 })
1246}
1247
1248impl<T: for<'a> FromRow<'a, PgRow> + Send + Unpin> PostgresRepository<T> {
1249 pub async fn aggregate_with_filters(
1257 &self,
1258 spec: &AggregateSpec,
1259 filters: &HashMap<String, String>,
1260 column_types: &HashMap<String, String>,
1261 search_fields: &[&str],
1262 ) -> anyhow::Result<AggregateResult> {
1263 self.aggregate_with_filters_and_base(spec, None, filters, column_types, search_fields)
1264 .await
1265 }
1266
1267 pub(crate) async fn aggregate_with_filters_and_base(
1271 &self,
1272 spec: &AggregateSpec,
1273 base_condition: Option<&str>,
1274 filters: &HashMap<String, String>,
1275 column_types: &HashMap<String, String>,
1276 search_fields: &[&str],
1277 ) -> anyhow::Result<AggregateResult> {
1278 let mut query_filter = self.parse_typed_filters(filters, column_types, None).await?;
1279 if let Some(cond) = base_condition {
1280 query_filter.add_base_condition(cond);
1281 }
1282 if !search_fields.is_empty() {
1283 query_filter.search_fields = search_fields.iter().map(|s| s.to_string()).collect();
1284 }
1285 query_filter.limit = None;
1288 query_filter.offset = None;
1289 let (where_clause, filter_params) = query_filter.build_where_clause();
1290
1291 let columns = catalog_columns(&self.pool, &self.table_name).await?;
1293 let mut selects: Vec<String> = Vec::new();
1294 let mut value_keys: Vec<String> = Vec::new();
1295 for (func, field) in &spec.reductions {
1296 let (column, pg_type) = resolve_column(field, &columns)?;
1297 if func.requires_numeric() && !is_numeric_pg_type(pg_type) {
1298 return Err(AggregateFieldError(format!(
1299 "cannot {} `{}`: its type is {} — {} needs a numeric column",
1300 func.label(),
1301 column,
1302 pg_type,
1303 func.label()
1304 ))
1305 .into());
1306 }
1307 let key = format!("{}:{}", func.label(), column);
1308 selects.push(format!("{}({})::text AS \"{}\"", func.sql(), column, key));
1311 value_keys.push(key);
1312 }
1313
1314 let group_limit = if spec.group_limit == 0 { DEFAULT_GROUP_LIMIT } else { spec.group_limit };
1315 let reductions = if selects.is_empty() { String::new() } else { format!(", {}", selects.join(", ")) };
1316
1317 let mut label_select = String::new();
1323 let mut label_join = String::new();
1324 if let (Some(field), Some(label_field), Some((rel_table, base_fk))) = (
1325 &spec.group_by,
1326 &spec.label_field,
1327 &spec.label_relation,
1328 ) {
1329 let _ = field;
1330 let qualified = qualify_relation_table(&self.table_name, rel_table);
1331 let rel_columns = catalog_columns(&self.pool, &qualified).await?;
1332 let (label_col, _) = resolve_column(label_field, &rel_columns).map_err(|_| {
1333 AggregateFieldError(format!(
1334 "cannot label groups by `{label_field}`: the related table `{qualified}` has no such column"
1335 ))
1336 })?;
1337 label_select = format!(", (label_rel.{label_col})::text AS __group_label");
1338 label_join = format!(
1339 " LEFT JOIN {qualified} AS label_rel ON label_rel.id IS NOT DISTINCT FROM {base_fk}"
1340 );
1341 }
1342
1343 let sql = match &spec.group_by {
1344 Some(field) => {
1345 let (column, _) = resolve_column(field, &columns)?;
1346 format!(
1347 "SELECT GROUPING({column}) AS __is_total, ({column})::text AS __group_key{label_select}, \
1348 COUNT(*) AS __count{reductions} \
1349 FROM {table}{label_join}{where_clause} \
1350 GROUP BY GROUPING SETS (({column}), ()) \
1351 ORDER BY __is_total DESC, __count DESC \
1352 LIMIT {limit}",
1353 column = column,
1354 label_select = label_select,
1355 label_join = label_join,
1356 reductions = reductions,
1357 table = self.table_name,
1358 where_clause = where_clause,
1359 limit = group_limit + 2,
1362 )
1363 }
1364 None => format!(
1365 "SELECT 1 AS __is_total, NULL::text AS __group_key, COUNT(*) AS __count{reductions} \
1366 FROM {table}{where_clause}",
1367 reductions = reductions,
1368 table = self.table_name,
1369 where_clause = where_clause,
1370 ),
1371 };
1372
1373 let mut builder = sqlx::query(&sql);
1374 for param in &filter_params {
1375 builder = builder.bind(param);
1376 }
1377 let rows = crate::company_scope::fetch_all_rows_scoped(&self.pool, builder).await?;
1378
1379 let read_group = |row: &PgRow| -> AggregateGroup {
1380 use sqlx::Row as _;
1381 let mut values = HashMap::with_capacity(value_keys.len());
1382 for key in &value_keys {
1383 values.insert(key.clone(), row.try_get::<Option<String>, _>(key.as_str()).ok().flatten());
1384 }
1385 AggregateGroup {
1386 key: row.try_get::<Option<String>, _>("__group_key").ok().flatten(),
1387 label: row.try_get::<Option<String>, _>("__group_label").ok().flatten(),
1388 count: row.try_get::<i64, _>("__count").unwrap_or(0).max(0) as u64,
1389 values,
1390 }
1391 };
1392
1393 use sqlx::Row as _;
1394 let mut total: Option<AggregateGroup> = None;
1395 let mut groups: Vec<AggregateGroup> = Vec::new();
1396 for row in &rows {
1397 let is_total = row.try_get::<i32, _>("__is_total").unwrap_or(0) == 1;
1398 if is_total {
1399 total = Some(read_group(row));
1401 } else {
1402 groups.push(read_group(row));
1403 }
1404 }
1405
1406 let truncated = groups.len() > group_limit;
1407 groups.truncate(group_limit);
1408
1409 let total = total.unwrap_or_else(|| AggregateGroup {
1412 key: None,
1413 label: None,
1414 count: 0,
1415 values: value_keys.iter().map(|k| (k.clone(), None)).collect(),
1416 });
1417
1418 Ok(AggregateResult { groups, total, truncated })
1419 }
1420}
1421
1422#[cfg(test)]
1423mod aggregate_field_tests {
1424 use super::*;
1425
1426 fn columns() -> HashMap<String, String> {
1427 [
1428 ("status", "text"),
1429 ("total", "numeric"),
1430 ("qty", "integer"),
1431 ("notes", "text"),
1432 ]
1433 .iter()
1434 .map(|(k, v)| (k.to_string(), v.to_string()))
1435 .collect()
1436 }
1437
1438 #[test]
1439 fn resolves_only_declared_columns() {
1440 let cols = columns();
1441 assert_eq!(resolve_column("total", &cols).unwrap().0, "total");
1442 assert!(resolve_column("password_hash", &cols).is_err());
1443 }
1444
1445 #[test]
1448 fn rejects_injection_attempts_rather_than_escaping_them() {
1449 let cols = columns();
1450 for probe in [
1451 "total) FROM selling.sales_orders; DROP TABLE users --",
1452 "status\"",
1453 "1=1",
1454 "total, (SELECT password FROM users)",
1455 "",
1456 ] {
1457 assert!(
1458 resolve_column(probe, &cols).is_err(),
1459 "`{probe}` must be refused, never escaped into the query"
1460 );
1461 }
1462 }
1463
1464 #[test]
1467 fn returns_the_declared_key_not_the_callers_string() {
1468 let cols = columns();
1469 let (name, _) = resolve_column("total", &cols).unwrap();
1470 assert!(std::ptr::eq(name, cols.get_key_value("total").unwrap().0.as_str()));
1471 }
1472
1473 #[test]
1474 fn sum_and_avg_require_a_numeric_type() {
1475 assert!(AggregateFn::Sum.requires_numeric());
1476 assert!(AggregateFn::Avg.requires_numeric());
1477 assert!(!AggregateFn::Min.requires_numeric());
1479 assert!(!AggregateFn::Max.requires_numeric());
1480 }
1481
1482 #[test]
1483 fn recognises_the_numeric_postgres_types() {
1484 for t in ["numeric", "NUMERIC(14,2)", "integer", "bigint", "double precision", "money"] {
1485 assert!(is_numeric_pg_type(t), "{t} should count as numeric");
1486 }
1487 for t in ["text", "uuid", "timestamptz", "boolean", "jsonb", "USER-DEFINED"] {
1488 assert!(!is_numeric_pg_type(t), "{t} must not accept a SUM");
1489 }
1490 }
1491}
1492
1493#[cfg(test)]
1494mod filter_cast_tests {
1495 use super::*;
1496
1497 #[test]
1498 fn typed_base_columns_get_their_own_cast() {
1499 for (t, want) in [
1500 ("boolean", "boolean"),
1501 ("integer", "integer"),
1502 ("bigint", "bigint"),
1503 ("smallint", "smallint"),
1504 ("numeric", "numeric"),
1505 ("double precision", "double precision"),
1506 ("uuid", "uuid"),
1507 ("date", "date"),
1508 ("time without time zone", "time"),
1509 ("timestamp with time zone", "timestamptz"),
1510 ("timestamp without time zone", "timestamp"),
1511 ] {
1512 assert_eq!(filter_cast_for(t, "b").as_deref(), Some(want), "{t}");
1513 }
1514 }
1515
1516 #[test]
1517 fn text_like_and_composite_columns_keep_the_text_bind() {
1518 for t in ["text", "character varying", "character", "jsonb", "json", "bytea", "text[]", "uuid[]"] {
1519 assert_eq!(filter_cast_for(t, "b"), None, "{t}");
1520 }
1521 assert_eq!(filter_cast_for("approvals.money_amount", "d"), None);
1523 assert_eq!(filter_cast_for("approvals.address", "c"), None);
1524 }
1525
1526 #[test]
1527 fn an_enum_casts_to_its_catalog_name() {
1528 assert_eq!(filter_cast_for("approval_status", "e").as_deref(), Some("approval_status"));
1529 assert_eq!(
1530 filter_cast_for("recruitment.stage_kind", "e").as_deref(),
1531 Some("recruitment.stage_kind")
1532 );
1533 }
1534
1535 #[test]
1536 fn a_generated_hint_wins_over_the_catalog_for_its_column() {
1537 let hints: HashMap<String, String> =
1538 [("id", "uuid"), ("status", "approval_status")].iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
1539 let catalog: HashMap<String, String> = [
1540 ("id", "uuid"),
1541 ("status", "approvals.approval_status"),
1542 ("folded", "boolean"),
1543 ("requested_by", "uuid"),
1544 ]
1545 .iter()
1546 .map(|(k, v)| (k.to_string(), v.to_string()))
1547 .collect();
1548 let merged = merge_filter_casts(&hints, catalog);
1549 assert_eq!(merged["status"], "approvals.approval_status");
1552 assert_eq!(merged["id"], "uuid");
1553 assert_eq!(merged["folded"], "boolean");
1554 assert_eq!(merged["requested_by"], "uuid");
1555 assert_eq!(merged.len(), 4);
1556 }
1557
1558 #[test]
1559 fn a_hint_for_another_type_still_decides_its_column() {
1560 let hints: HashMap<String, String> =
1561 [("amount", "numeric")].iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
1562 let catalog: HashMap<String, String> =
1563 [("amount", "real")].iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
1564 assert_eq!(merge_filter_casts(&hints, catalog)["amount"], "numeric");
1565 }
1566
1567 #[test]
1568 fn a_filter_on_a_bare_enum_hint_reads_the_catalog_to_qualify_it() {
1569 let hints: HashMap<String, String> = [("id", "uuid"), ("status", "task_status")]
1570 .iter()
1571 .map(|(k, v)| (k.to_string(), v.to_string()))
1572 .collect();
1573 let needs = |pairs: &[(&str, &str)]| {
1574 let f: HashMap<String, String> =
1575 pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
1576 parse_query_filter(&f, &hints, None).unwrap().needs_catalog_types()
1577 };
1578 assert!(needs(&[("status[in]", "done,skipped")]));
1579 assert!(needs(&[("status[eq]", "done")]));
1580 assert!(!needs(&[("id[in]", "a,b")]));
1582 }
1583
1584 #[test]
1585 fn a_sort_on_an_enum_casts_to_its_schema_qualified_name() {
1586 assert_eq!(
1587 cast_suffix("USER-DEFINED", "task_status", "lifecycle").as_deref(),
1588 Some("lifecycle.task_status")
1589 );
1590 assert_eq!(cast_suffix("USER-DEFINED", "mood", "public").as_deref(), Some("mood"));
1591 assert_eq!(cast_suffix("uuid", "uuid", "pg_catalog").as_deref(), Some("uuid"));
1592 }
1593
1594 #[test]
1595 fn only_a_filter_with_an_uncast_comparison_reads_the_catalog() {
1596 let hints: HashMap<String, String> =
1597 [("id", "uuid")].iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
1598 let parse = |pairs: &[(&str, &str)]| {
1599 let f: HashMap<String, String> =
1600 pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
1601 parse_query_filter(&f, &hints, None).unwrap().has_uncast_value_conditions()
1602 };
1603 assert!(!parse(&[("id[in]", "a,b"), ("name[contain]", "x"), ("limit", "5")]));
1604 assert!(!parse(&[("deleted_by[isnull]", "1")]));
1605 assert!(parse(&[("folded[eq]", "false")]));
1606 assert!(parse(&[("folded", "false")]));
1607 assert!(parse(&[("scheduled_at[between]", "2026-10-01,2026-10-03")]));
1608 assert!(parse(&[("sequence[or]", "3")]));
1609 }
1610}
1611
1612#[cfg(test)]
1613mod keyset_predicate_tests {
1614 use super::and_keyset_predicate;
1615
1616 #[test]
1617 fn a_client_or_cannot_escape_the_keyset_window() {
1618 assert_eq!(
1619 and_keyset_predicate(" WHERE kind = $1 OR status = $2", "id > $3"),
1620 " WHERE (kind = $1 OR status = $2) AND (id > $3)"
1621 );
1622 }
1623
1624 #[test]
1625 fn no_filter_leaves_the_keyset_alone() {
1626 assert_eq!(and_keyset_predicate("", "id > $1"), " WHERE id > $1");
1627 }
1628}
1629
1630#[cfg(test)]
1631mod changed_columns_tests {
1632 use super::changed_columns;
1633
1634 #[derive(serde::Serialize)]
1635 struct Row {
1636 id: &'static str,
1637 notes: &'static str,
1638 status: &'static str,
1639 #[serde(skip_serializing_if = "Option::is_none")]
1640 label: Option<&'static str>,
1641 }
1642
1643 #[test]
1644 fn only_the_fields_that_differ_are_changed() {
1645 let before = Row {
1646 id: "1",
1647 notes: "a",
1648 status: "draft",
1649 label: None,
1650 };
1651 let after = Row {
1652 id: "2",
1653 notes: "b",
1654 status: "draft",
1655 label: Some("x"),
1656 };
1657 assert_eq!(
1658 changed_columns(&before, &after).unwrap(),
1659 vec!["label".to_string(), "notes".to_string()]
1660 );
1661 assert!(changed_columns(&before, &before).unwrap().is_empty());
1662 }
1663}