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 json_obj = match serde_json::to_value(entity)? {
373 Value::Object(obj) => obj,
374 _ => return Err(anyhow::anyhow!("Entity must serialize to a JSON object")),
375 };
376
377 let update_columns: Vec<&String> = json_obj
378 .keys()
379 .filter(|k| *k != "id")
380 .filter(|k| only.is_none_or(|cols| cols.iter().any(|c| c == *k)))
381 .collect();
382
383 if update_columns.is_empty() {
384 let query = format!(
385 "SELECT t.* FROM {} AS t WHERE t.id = $1::uuid{}",
386 self.table_name, guard
387 );
388 return Ok(crate::company_scope::fetch_optional_scoped(
389 &self.pool,
390 sqlx::query_as::<_, T>(&query).bind(id),
391 )
392 .await?);
393 }
394
395 let column_names = update_columns
396 .iter()
397 .map(|k| quote_ident(k))
398 .collect::<Vec<_>>()
399 .join(", ");
400 let json_str = serde_json::to_string(&json_obj)?;
401
402 let query = format!(
405 r#"
406 WITH new_row AS (
407 SELECT (jsonb_populate_record(NULL::{table}, $1::jsonb)).*
408 )
409 UPDATE {table} AS t
410 SET ({columns}) = (SELECT {columns} FROM new_row)
411 WHERE t.id = $2::uuid{guard}
412 RETURNING t.*
413 "#,
414 table = self.table_name,
415 columns = column_names,
416 guard = guard,
417 );
418
419 let result = crate::company_scope::fetch_optional_scoped(
420 &self.pool,
421 sqlx::query_as::<_, T>(&query).bind(&json_str).bind(id),
422 )
423 .await?;
424
425 Ok(result)
426 }
427
428 async fn parse_typed_filters(
443 &self,
444 filters: &HashMap<String, String>,
445 column_types: &HashMap<String, String>,
446 allowed_fields: Option<&HashSet<String>>,
447 ) -> anyhow::Result<crate::QueryFilter> {
448 let query_filter = parse_query_filter(filters, column_types, allowed_fields)?;
449 if !query_filter.needs_catalog_types() {
450 return Ok(query_filter);
451 }
452 let catalog = catalog_filter_casts(&self.pool, &self.table_name).await?;
453 let merged = merge_filter_casts(column_types, catalog);
454 parse_query_filter(filters, &merged, allowed_fields)
455 }
456
457 #[allow(clippy::type_complexity)]
471 async fn execute_list(
472 &self,
473 pagination: PaginationParams,
474 query_filter: crate::QueryFilter,
475 ) -> anyhow::Result<PaginatedResult<T>> {
476 let limit = pagination.limit() as i64;
477 let backwards =
478 query_filter.cursor_before.is_some() && query_filter.cursor_after.is_none();
479 let cursor_walk = query_filter.cursor_after.is_some() || backwards;
480
481 let (mut where_clause, mut filter_params) = query_filter.build_where_clause();
482 let order_clause;
483 let mut boundary_casts: Vec<Option<String>> = Vec::new();
485
486 let mut sorts: Vec<(String, FilterSortDirection)> = query_filter
491 .sorts
492 .iter()
493 .map(|s| (s.field.clone(), s.direction.clone()))
494 .collect();
495 if cursor_walk || !sorts.is_empty() {
496 if sorts.is_empty() {
497 sorts.push(("id".into(), FilterSortDirection::Asc));
498 } else if sorts.last().map(|(f, _)| f != "id").unwrap_or(true) {
499 sorts.push(("id".into(), FilterSortDirection::Asc));
500 }
501 }
502
503 if !sorts.is_empty() {
506 boundary_casts = self.sort_column_casts(&sorts).await?;
507 }
508
509 if cursor_walk {
510 let opaque = if backwards {
511 query_filter.cursor_before.clone().unwrap()
512 } else {
513 query_filter.cursor_after.clone().unwrap()
514 };
515 let payload = crate::filter::cursor::decode_cursor(&opaque, &sorts)
516 .map_err(|e| anyhow::anyhow!("cursor refused: {e}"))?;
517 let mut idx = filter_params.len() + 1;
518 let (keyset_sql, keyset_params) = crate::filter::cursor::build_keyset_predicate(
519 &payload,
520 &mut idx,
521 &boundary_casts,
522 backwards,
523 );
524 where_clause = and_keyset_predicate(&where_clause, &keyset_sql);
525 filter_params.extend(keyset_params);
526 let parts: Vec<String> = sorts
527 .iter()
528 .map(|(f, d)| {
529 let dir = if (*d == FilterSortDirection::Desc) != backwards {
530 "DESC"
531 } else {
532 "ASC"
533 };
534 format!("{} {}", f, dir)
535 })
536 .collect();
537 order_clause = format!(" ORDER BY {}", parts.join(", "));
538 } else {
539 if sorts.is_empty() {
542 order_clause = query_filter.build_order_by_clause();
543 } else {
544 let parts: Vec<String> = sorts
545 .iter()
546 .map(|(f, d)| {
547 let dir =
548 if *d == FilterSortDirection::Desc { "DESC" } else { "ASC" };
549 format!("{} {}", f, dir)
550 })
551 .collect();
552 order_clause = format!(" ORDER BY {}", parts.join(", "));
553 }
554 }
555 let boundary_sorts: Vec<(String, FilterSortDirection)> = sorts;
557
558 let (total, count_mode) = if query_filter.estimate_total {
564 let estimate = self.estimate_filtered_rows(&where_clause, &filter_params).await?;
565 if estimate <= EXACT_COUNT_BELOW {
566 (self.count_filtered_rows(&where_clause, &filter_params).await?, "exact")
567 } else {
568 (estimate, "estimate")
569 }
570 } else if cursor_walk {
571 (0u64, "none")
572 } else {
573 (self.count_filtered_rows(&where_clause, &filter_params).await?, "exact")
574 };
575
576 let fetch = limit + 1;
579 let data_query = if cursor_walk {
580 format!(
581 "SELECT * FROM {}{}{} LIMIT {}",
582 self.table_name, where_clause, order_clause, fetch
583 )
584 } else {
585 format!(
586 "SELECT * FROM {}{}{} LIMIT {} OFFSET {}",
587 self.table_name,
588 where_clause,
589 order_clause,
590 fetch,
591 pagination.offset()
592 )
593 };
594
595 let mut pagination_info = PaginationInfo::new(pagination.page, pagination.per_page, total);
596 pagination_info.count_mode = count_mode.to_string();
597
598 let mut rows_query = sqlx::query(&data_query);
603 for param in &filter_params {
604 rows_query = rows_query.bind(param);
605 }
606 let rows: Vec<PgRow> =
607 crate::company_scope::fetch_all_rows_scoped(&self.pool, rows_query).await?;
608 let has_more = rows.len() as i64 > limit;
609 let mut page: Vec<PgRow> = rows.into_iter().take(limit as usize).collect();
610 if backwards {
611 page.reverse();
612 }
613 let data: anyhow::Result<Vec<T>> = page
614 .iter()
615 .map(|row| T::from_row(row).map_err(|e| anyhow::anyhow!("decode row: {e}")))
616 .collect();
617 let data = data?;
618
619 let deterministic = !boundary_sorts.is_empty();
623 let next_cursor = if (has_more || backwards) && deterministic {
624 page.last().and_then(|r| {
625 let casts = if boundary_casts.is_empty() {
626 &boundary_casts
629 } else {
630 &boundary_casts
631 };
632 self.row_cursor(r, &boundary_sorts, casts)
633 })
634 } else {
635 None
636 };
637 let prev_cursor = if !page.is_empty() && deterministic {
638 page.first().and_then(|r| self.row_cursor(r, &boundary_sorts, &boundary_casts))
639 } else {
640 None
641 };
642
643 pagination_info.has_more = Some(has_more);
644 pagination_info.next_cursor = next_cursor;
645 pagination_info.prev_cursor = prev_cursor;
646
647 Ok(PaginatedResult {
648 data,
649 pagination: pagination_info,
650 })
651 }
652
653 async fn count_filtered_rows(
658 &self,
659 where_clause: &str,
660 filter_params: &[String],
661 ) -> anyhow::Result<u64> {
662 let count_query = format!("SELECT COUNT(*) FROM {}{}", self.table_name, where_clause);
663 let mut count_query_builder = sqlx::query_scalar::<_, i64>(&count_query);
664 for param in filter_params {
665 count_query_builder = count_query_builder.bind(param);
666 }
667 Ok(crate::company_scope::fetch_one_scalar_scoped(&self.pool, count_query_builder).await?
668 as u64)
669 }
670
671 async fn estimate_filtered_rows(
672 &self,
673 where_clause: &str,
674 filter_params: &[String],
675 ) -> anyhow::Result<u64> {
676 let explain = format!("EXPLAIN (FORMAT JSON) SELECT 1 FROM {}{}", self.table_name, where_clause);
677 let mut builder = sqlx::query_scalar::<_, serde_json::Value>(&explain);
678 for param in filter_params {
679 builder = builder.bind(param);
680 }
681 let plan: serde_json::Value =
682 crate::company_scope::fetch_one_scalar_scoped(&self.pool, builder).await?;
683 let rows = plan
684 .as_array()
685 .and_then(|a| a.first())
686 .and_then(|top| top.get("Plan"))
687 .and_then(|p| p.get("Plan Rows"))
688 .and_then(|r| r.as_i64())
689 .unwrap_or(0);
690 Ok(rows.max(0) as u64)
691 }
692
693 async fn sort_column_casts(
698 &self,
699 sorts: &[(String, FilterSortDirection)],
700 ) -> anyhow::Result<Vec<Option<String>>> {
701 let (schema, table) = match self.table_name.rsplit_once('.') {
702 Some((s, t)) => (s.to_string(), t.to_string()),
703 None => ("public".to_string(), self.table_name.clone()),
704 };
705 let mut casts: Vec<Option<String>> = Vec::with_capacity(sorts.len());
706 for (field, _) in sorts {
707 let row: Option<(String, String, String)> = sqlx::query_as(
708 "SELECT data_type, coalesce(udt_name, ''), coalesce(udt_schema, '') \
709 FROM information_schema.columns \
710 WHERE table_schema = $1 AND table_name = $2 AND column_name = $3",
711 )
712 .bind(&schema)
713 .bind(&table)
714 .bind(field)
715 .fetch_optional(&self.pool)
716 .await?;
717 let cast = row
718 .map(|(data_type, udt, udt_schema)| cast_suffix(&data_type, &udt, &udt_schema))
719 .flatten();
720 casts.push(cast);
721 }
722 Ok(casts)
723 }
724
725 fn row_cursor(
729 &self,
730 row: &PgRow,
731 sorts: &[(String, FilterSortDirection)],
732 casts: &[Option<String>],
733 ) -> Option<String> {
734 let id: uuid::Uuid = row.try_get("id").ok()?;
735 let mut values: Vec<serde_json::Value> = Vec::with_capacity(sorts.len());
736 for (i, (field, _)) in sorts.iter().enumerate() {
737 let field = field.as_str();
738 let mut data_type = casts.get(i).and_then(|c| c.as_deref()).unwrap_or("");
739 if field == "id" && data_type.is_empty() {
743 data_type = "uuid";
744 }
745 let text: Option<String> = match data_type {
746 "numeric" => row
747 .try_get::<Option<sqlx::types::Decimal>, _>(field)
748 .ok()?
749 .map(|d| d.to_string()),
750 "uuid" => row
751 .try_get::<Option<uuid::Uuid>, _>(field)
752 .ok()?
753 .map(|u| u.to_string()),
754 "timestamptz" => row
755 .try_get::<Option<chrono::DateTime<chrono::Utc>>, _>(field)
756 .ok()?
757 .map(|t| t.to_rfc3339()),
758 "integer" | "smallint" => row
759 .try_get::<Option<i32>, _>(field)
760 .ok()?
761 .map(|n| n.to_string()),
762 "bigint" => row
763 .try_get::<Option<i64>, _>(field)
764 .ok()?
765 .map(|n| n.to_string()),
766 "boolean" => row
767 .try_get::<Option<bool>, _>(field)
768 .ok()?
769 .map(|b| b.to_string()),
770 "date" => row
771 .try_get::<Option<chrono::NaiveDate>, _>(field)
772 .ok()?
773 .map(|d| d.to_string()),
774 _ => row
775 .try_get::<Option<String>, _>(field)
776 .ok()?
777 .filter(|s| !s.is_empty() || data_type.is_empty()),
778 };
779 values.push(serde_json::Value::String(text?));
780 }
781 crate::filter::cursor::encode_cursor(sorts, &values, &id.to_string()).ok()
782 }
783}
784
785pub fn changed_columns<T: Serialize>(before: &T, after: &T) -> anyhow::Result<Vec<String>> {
792 let (Value::Object(before), Value::Object(after)) =
793 (serde_json::to_value(before)?, serde_json::to_value(after)?)
794 else {
795 return Err(anyhow::anyhow!("Entity must serialize to a JSON object"));
796 };
797 Ok(after
798 .into_iter()
799 .filter(|(k, v)| k != "id" && before.get(k) != Some(v))
800 .map(|(k, _)| k)
801 .collect())
802}
803
804fn and_keyset_predicate(where_clause: &str, keyset_sql: &str) -> String {
808 match where_clause.trim_start().strip_prefix("WHERE ") {
809 None => format!(" WHERE {}", keyset_sql),
810 Some(existing) => format!(" WHERE ({}) AND ({})", existing, keyset_sql),
811 }
812}
813
814fn cast_suffix(data_type: &str, udt_name: &str, udt_schema: &str) -> Option<String> {
815 match data_type {
816 "uuid" => Some("uuid".into()),
817 "numeric" => Some("numeric".into()),
818 "integer" => Some("integer".into()),
819 "smallint" => Some("smallint".into()),
820 "bigint" => Some("bigint".into()),
821 "boolean" => Some("boolean".into()),
822 "date" => Some("date".into()),
823 "timestamp with time zone" => Some("timestamptz".into()),
824 "timestamp without time zone" => Some("timestamp".into()),
825 "USER-DEFINED" if !udt_name.is_empty() => Some(match udt_schema {
829 "" | "public" | "pg_catalog" => udt_name.to_string(),
830 schema => format!("{schema}.{udt_name}"),
831 }),
832 _ => None,
833 }
834}
835
836fn filter_cast_for(type_name: &str, typtype: &str) -> Option<String> {
841 match typtype {
842 "e" => Some(type_name.to_string()),
845 "b" => match type_name {
846 "uuid" | "boolean" | "smallint" | "integer" | "bigint" | "numeric" | "real"
847 | "double precision" | "date" | "interval" | "inet" | "cidr" | "macaddr" => {
848 Some(type_name.to_string())
849 }
850 "time without time zone" => Some("time".into()),
851 "time with time zone" => Some("timetz".into()),
852 "timestamp with time zone" => Some("timestamptz".into()),
853 "timestamp without time zone" => Some("timestamp".into()),
854 _ => None,
855 },
856 _ => None,
857 }
858}
859
860async fn catalog_filter_casts(
867 pool: &PgPool,
868 qualified_table: &str,
869) -> anyhow::Result<HashMap<String, String>> {
870 let q = sqlx::query_as::<Postgres, (String, String, String)>(
871 "SELECT a.attname::text, format_type(a.atttypid, NULL), t.typtype::text
872 FROM pg_catalog.pg_attribute a
873 JOIN pg_catalog.pg_type t ON t.oid = a.atttypid
874 WHERE a.attrelid = to_regclass($1) AND a.attnum > 0 AND NOT a.attisdropped",
875 )
876 .bind(qualified_table.to_string());
877 let rows = crate::company_scope::fetch_all_scoped(pool, q).await?;
878 Ok(rows
879 .into_iter()
880 .filter_map(|(column, type_name, typtype)| {
881 filter_cast_for(&type_name, &typtype).map(|cast| (column, cast))
882 })
883 .collect())
884}
885
886fn merge_filter_casts(
891 hints: &HashMap<String, String>,
892 mut catalog: HashMap<String, String>,
893) -> HashMap<String, String> {
894 for (column, cast) in hints {
895 let qualified_same_type = catalog
896 .get(column)
897 .and_then(|c| c.rsplit_once('.'))
898 .is_some_and(|(_, name)| name.trim_matches('"') == cast.as_str());
899 if !qualified_same_type {
900 catalog.insert(column.clone(), cast.clone());
901 }
902 }
903 catalog
904}
905
906fn explain_unknown_column(error: sqlx::Error, table: &str) -> anyhow::Error {
914 let text = error.to_string();
915 if text.contains("does not exist") && text.contains("column") {
916 return anyhow::Error::new(error).context(format!(
917 "insert into {table} named a column that does not exist: the entity serializes a field \
918 with no matching column. Every serialized field must be a column of the table (rename \
919 it, map it with #[serde(rename)], or skip it with #[serde(skip)])"
920 ));
921 }
922 anyhow::Error::new(error)
923}
924
925fn quote_ident(name: &str) -> String {
932 format!("\"{}\"", name.replace('"', "\"\""))
933}
934
935#[async_trait]
936impl<T> DatabaseOperations<T> for PostgresRepository<T>
937where
938 T: for<'a> FromRow<'a, PgRow> + Send + Sync + Unpin + Serialize,
939{
940 async fn create(&self, entity: &T) -> anyhow::Result<T> {
941 let json_value = serde_json::to_value(entity)?;
943
944 let json_obj = match json_value {
945 Value::Object(obj) => obj,
946 _ => return Err(anyhow::anyhow!("Entity must serialize to a JSON object")),
947 };
948
949 let json_str = serde_json::to_string(&json_obj)?;
952
953 let insert_columns: Vec<String> = json_obj.keys().map(|k| quote_ident(k)).collect();
967
968 let query = if insert_columns.is_empty() {
969 format!("INSERT INTO {table} DEFAULT VALUES RETURNING *", table = self.table_name)
972 } else {
973 let columns = insert_columns.join(", ");
974 format!(
975 r#"
976 INSERT INTO {table} ({columns})
977 SELECT {columns} FROM jsonb_populate_record(NULL::{table}, $1::jsonb)
978 RETURNING *
979 "#,
980 table = self.table_name,
981 columns = columns
982 )
983 };
984
985 let statement = if insert_columns.is_empty() {
987 sqlx::query_as::<_, T>(&query)
988 } else {
989 sqlx::query_as::<_, T>(&query).bind(&json_str)
990 };
991 let result = crate::company_scope::fetch_one_scoped(&self.pool, statement)
992 .await
993 .map_err(|e| explain_unknown_column(e, &self.table_name))?;
994
995 Ok(result)
996 }
997
998 async fn find_by_id(&self, id: &str) -> anyhow::Result<Option<T>> {
999 let query = format!("SELECT * FROM {} WHERE id = $1::uuid", self.table_name);
1001 let result = crate::company_scope::fetch_optional_scoped(
1002 &self.pool,
1003 sqlx::query_as::<Postgres, T>(&query).bind(id),
1004 )
1005 .await?;
1006 Ok(result)
1007 }
1008
1009 async fn find_all(&self) -> anyhow::Result<Vec<T>> {
1010 let query = format!("SELECT * FROM {}", self.table_name);
1011 let results = crate::company_scope::fetch_all_scoped(
1012 &self.pool,
1013 sqlx::query_as::<Postgres, T>(&query),
1014 )
1015 .await?;
1016 Ok(results)
1017 }
1018
1019 async fn update(&self, id: &str, entity: &T) -> anyhow::Result<Option<T>> {
1020 self.update_guarded(id, entity, None, "").await
1021 }
1022
1023 async fn delete(&self, id: &str) -> anyhow::Result<bool> {
1024 let query = format!("DELETE FROM {} WHERE id = $1::uuid", self.table_name);
1025 let result = crate::company_scope::execute_scoped(
1026 &self.pool,
1027 sqlx::query(&query).bind(id),
1028 )
1029 .await?;
1030 Ok(result.rows_affected() > 0)
1031 }
1032
1033 async fn count(&self) -> anyhow::Result<u64> {
1034 let query = format!("SELECT COUNT(*) FROM {}", self.table_name);
1035 let count = crate::company_scope::fetch_one_scalar_scoped(
1036 &self.pool,
1037 sqlx::query_scalar::<_, i64>(&query),
1038 )
1039 .await? as u64;
1040 Ok(count)
1041 }
1042
1043 async fn exists(&self, id: &str) -> anyhow::Result<bool> {
1044 let query = format!("SELECT 1 FROM {} WHERE id = $1::uuid LIMIT 1", self.table_name);
1045 let result = crate::company_scope::fetch_optional_scalar_scoped(
1046 &self.pool,
1047 sqlx::query_scalar::<_, i32>(&query).bind(id),
1048 )
1049 .await?;
1050 Ok(result.is_some())
1051 }
1052
1053 async fn execute_query(&self, query: &str) -> anyhow::Result<u64> {
1054 let result = crate::company_scope::execute_scoped(
1055 &self.pool,
1056 sqlx::query(query),
1057 )
1058 .await?;
1059 Ok(result.rows_affected())
1060 }
1061}
1062
1063#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1067pub enum AggregateFn {
1068 Sum,
1069 Avg,
1070 Min,
1071 Max,
1072}
1073
1074impl AggregateFn {
1075 fn sql(self) -> &'static str {
1076 match self {
1077 AggregateFn::Sum => "SUM",
1078 AggregateFn::Avg => "AVG",
1079 AggregateFn::Min => "MIN",
1080 AggregateFn::Max => "MAX",
1081 }
1082 }
1083
1084 fn label(self) -> &'static str {
1085 match self {
1086 AggregateFn::Sum => "sum",
1087 AggregateFn::Avg => "avg",
1088 AggregateFn::Min => "min",
1089 AggregateFn::Max => "max",
1090 }
1091 }
1092
1093 fn requires_numeric(self) -> bool {
1096 matches!(self, AggregateFn::Sum | AggregateFn::Avg)
1097 }
1098}
1099
1100#[derive(Debug, Clone, Default)]
1102pub struct AggregateSpec {
1103 pub group_by: Option<String>,
1105 pub reductions: Vec<(AggregateFn, String)>,
1107 pub group_limit: usize,
1109 pub label_field: Option<String>,
1113 pub label_relation: Option<(String, String)>,
1117}
1118
1119pub const DEFAULT_GROUP_LIMIT: usize = 200;
1127
1128#[derive(Debug, Clone, Serialize, Deserialize)]
1131pub struct AggregateGroup {
1132 pub key: Option<String>,
1133 pub label: Option<String>,
1136 pub count: u64,
1137 pub values: HashMap<String, Option<String>>,
1145}
1146
1147#[derive(Debug, Clone, Serialize, Deserialize)]
1149pub struct AggregateResult {
1150 pub groups: Vec<AggregateGroup>,
1151 pub total: AggregateGroup,
1152 pub truncated: bool,
1154}
1155
1156#[derive(Debug, Clone)]
1159pub struct AggregateFieldError(pub String);
1160
1161impl std::fmt::Display for AggregateFieldError {
1162 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1163 f.write_str(&self.0)
1164 }
1165}
1166
1167impl std::error::Error for AggregateFieldError {}
1168
1169fn is_numeric_pg_type(pg_type: &str) -> bool {
1171 let t = pg_type.trim().to_ascii_lowercase();
1172 let t = t.split('(').next().unwrap_or(&t).trim();
1173 matches!(
1174 t,
1175 "numeric" | "decimal" | "money"
1176 | "smallint" | "int2" | "integer" | "int" | "int4" | "bigint" | "int8"
1177 | "real" | "float4" | "double precision" | "float8"
1178 | "smallserial" | "serial" | "bigserial"
1179 )
1180}
1181
1182async fn catalog_columns(
1191 pool: &PgPool,
1192 qualified_table: &str,
1193) -> anyhow::Result<HashMap<String, String>> {
1194 let (schema, table) = match qualified_table.split_once('.') {
1195 Some((s, t)) => (s.to_string(), t.to_string()),
1196 None => ("public".to_string(), qualified_table.to_string()),
1197 };
1198 let q = sqlx::query_as::<Postgres, (String, String)>(
1199 "SELECT column_name, data_type FROM information_schema.columns
1200 WHERE table_schema = $1 AND table_name = $2",
1201 )
1202 .bind(schema)
1203 .bind(table);
1204 let rows = crate::company_scope::fetch_all_scoped(pool, q).await?;
1205 Ok(rows.into_iter().collect())
1206}
1207
1208fn resolve_column<'a>(
1217 name: &str,
1218 column_types: &'a HashMap<String, String>,
1219) -> Result<(&'a str, &'a str), AggregateFieldError> {
1220 column_types
1221 .get_key_value(name)
1222 .map(|(k, v)| (k.as_str(), v.as_str()))
1223 .ok_or_else(|| {
1224 AggregateFieldError(format!("unknown field `{name}` — not a column of this entity"))
1225 })
1226}
1227
1228impl<T: for<'a> FromRow<'a, PgRow> + Send + Unpin> PostgresRepository<T> {
1229 pub async fn aggregate_with_filters(
1237 &self,
1238 spec: &AggregateSpec,
1239 filters: &HashMap<String, String>,
1240 column_types: &HashMap<String, String>,
1241 search_fields: &[&str],
1242 ) -> anyhow::Result<AggregateResult> {
1243 self.aggregate_with_filters_and_base(spec, None, filters, column_types, search_fields)
1244 .await
1245 }
1246
1247 pub(crate) async fn aggregate_with_filters_and_base(
1251 &self,
1252 spec: &AggregateSpec,
1253 base_condition: Option<&str>,
1254 filters: &HashMap<String, String>,
1255 column_types: &HashMap<String, String>,
1256 search_fields: &[&str],
1257 ) -> anyhow::Result<AggregateResult> {
1258 let mut query_filter = self.parse_typed_filters(filters, column_types, None).await?;
1259 if let Some(cond) = base_condition {
1260 query_filter.add_base_condition(cond);
1261 }
1262 if !search_fields.is_empty() {
1263 query_filter.search_fields = search_fields.iter().map(|s| s.to_string()).collect();
1264 }
1265 query_filter.limit = None;
1268 query_filter.offset = None;
1269 let (where_clause, filter_params) = query_filter.build_where_clause();
1270
1271 let columns = catalog_columns(&self.pool, &self.table_name).await?;
1273 let mut selects: Vec<String> = Vec::new();
1274 let mut value_keys: Vec<String> = Vec::new();
1275 for (func, field) in &spec.reductions {
1276 let (column, pg_type) = resolve_column(field, &columns)?;
1277 if func.requires_numeric() && !is_numeric_pg_type(pg_type) {
1278 return Err(AggregateFieldError(format!(
1279 "cannot {} `{}`: its type is {} — {} needs a numeric column",
1280 func.label(),
1281 column,
1282 pg_type,
1283 func.label()
1284 ))
1285 .into());
1286 }
1287 let key = format!("{}:{}", func.label(), column);
1288 selects.push(format!("{}({})::text AS \"{}\"", func.sql(), column, key));
1291 value_keys.push(key);
1292 }
1293
1294 let group_limit = if spec.group_limit == 0 { DEFAULT_GROUP_LIMIT } else { spec.group_limit };
1295 let reductions = if selects.is_empty() { String::new() } else { format!(", {}", selects.join(", ")) };
1296
1297 let mut label_select = String::new();
1303 let mut label_join = String::new();
1304 if let (Some(field), Some(label_field), Some((rel_table, base_fk))) = (
1305 &spec.group_by,
1306 &spec.label_field,
1307 &spec.label_relation,
1308 ) {
1309 let _ = field;
1310 let qualified = qualify_relation_table(&self.table_name, rel_table);
1311 let rel_columns = catalog_columns(&self.pool, &qualified).await?;
1312 let (label_col, _) = resolve_column(label_field, &rel_columns).map_err(|_| {
1313 AggregateFieldError(format!(
1314 "cannot label groups by `{label_field}`: the related table `{qualified}` has no such column"
1315 ))
1316 })?;
1317 label_select = format!(", (label_rel.{label_col})::text AS __group_label");
1318 label_join = format!(
1319 " LEFT JOIN {qualified} AS label_rel ON label_rel.id IS NOT DISTINCT FROM {base_fk}"
1320 );
1321 }
1322
1323 let sql = match &spec.group_by {
1324 Some(field) => {
1325 let (column, _) = resolve_column(field, &columns)?;
1326 format!(
1327 "SELECT GROUPING({column}) AS __is_total, ({column})::text AS __group_key{label_select}, \
1328 COUNT(*) AS __count{reductions} \
1329 FROM {table}{label_join}{where_clause} \
1330 GROUP BY GROUPING SETS (({column}), ()) \
1331 ORDER BY __is_total DESC, __count DESC \
1332 LIMIT {limit}",
1333 column = column,
1334 label_select = label_select,
1335 label_join = label_join,
1336 reductions = reductions,
1337 table = self.table_name,
1338 where_clause = where_clause,
1339 limit = group_limit + 2,
1342 )
1343 }
1344 None => format!(
1345 "SELECT 1 AS __is_total, NULL::text AS __group_key, COUNT(*) AS __count{reductions} \
1346 FROM {table}{where_clause}",
1347 reductions = reductions,
1348 table = self.table_name,
1349 where_clause = where_clause,
1350 ),
1351 };
1352
1353 let mut builder = sqlx::query(&sql);
1354 for param in &filter_params {
1355 builder = builder.bind(param);
1356 }
1357 let rows = crate::company_scope::fetch_all_rows_scoped(&self.pool, builder).await?;
1358
1359 let read_group = |row: &PgRow| -> AggregateGroup {
1360 use sqlx::Row as _;
1361 let mut values = HashMap::with_capacity(value_keys.len());
1362 for key in &value_keys {
1363 values.insert(key.clone(), row.try_get::<Option<String>, _>(key.as_str()).ok().flatten());
1364 }
1365 AggregateGroup {
1366 key: row.try_get::<Option<String>, _>("__group_key").ok().flatten(),
1367 label: row.try_get::<Option<String>, _>("__group_label").ok().flatten(),
1368 count: row.try_get::<i64, _>("__count").unwrap_or(0).max(0) as u64,
1369 values,
1370 }
1371 };
1372
1373 use sqlx::Row as _;
1374 let mut total: Option<AggregateGroup> = None;
1375 let mut groups: Vec<AggregateGroup> = Vec::new();
1376 for row in &rows {
1377 let is_total = row.try_get::<i32, _>("__is_total").unwrap_or(0) == 1;
1378 if is_total {
1379 total = Some(read_group(row));
1381 } else {
1382 groups.push(read_group(row));
1383 }
1384 }
1385
1386 let truncated = groups.len() > group_limit;
1387 groups.truncate(group_limit);
1388
1389 let total = total.unwrap_or_else(|| AggregateGroup {
1392 key: None,
1393 label: None,
1394 count: 0,
1395 values: value_keys.iter().map(|k| (k.clone(), None)).collect(),
1396 });
1397
1398 Ok(AggregateResult { groups, total, truncated })
1399 }
1400}
1401
1402#[cfg(test)]
1403mod aggregate_field_tests {
1404 use super::*;
1405
1406 fn columns() -> HashMap<String, String> {
1407 [
1408 ("status", "text"),
1409 ("total", "numeric"),
1410 ("qty", "integer"),
1411 ("notes", "text"),
1412 ]
1413 .iter()
1414 .map(|(k, v)| (k.to_string(), v.to_string()))
1415 .collect()
1416 }
1417
1418 #[test]
1419 fn resolves_only_declared_columns() {
1420 let cols = columns();
1421 assert_eq!(resolve_column("total", &cols).unwrap().0, "total");
1422 assert!(resolve_column("password_hash", &cols).is_err());
1423 }
1424
1425 #[test]
1428 fn rejects_injection_attempts_rather_than_escaping_them() {
1429 let cols = columns();
1430 for probe in [
1431 "total) FROM selling.sales_orders; DROP TABLE users --",
1432 "status\"",
1433 "1=1",
1434 "total, (SELECT password FROM users)",
1435 "",
1436 ] {
1437 assert!(
1438 resolve_column(probe, &cols).is_err(),
1439 "`{probe}` must be refused, never escaped into the query"
1440 );
1441 }
1442 }
1443
1444 #[test]
1447 fn returns_the_declared_key_not_the_callers_string() {
1448 let cols = columns();
1449 let (name, _) = resolve_column("total", &cols).unwrap();
1450 assert!(std::ptr::eq(name, cols.get_key_value("total").unwrap().0.as_str()));
1451 }
1452
1453 #[test]
1454 fn sum_and_avg_require_a_numeric_type() {
1455 assert!(AggregateFn::Sum.requires_numeric());
1456 assert!(AggregateFn::Avg.requires_numeric());
1457 assert!(!AggregateFn::Min.requires_numeric());
1459 assert!(!AggregateFn::Max.requires_numeric());
1460 }
1461
1462 #[test]
1463 fn recognises_the_numeric_postgres_types() {
1464 for t in ["numeric", "NUMERIC(14,2)", "integer", "bigint", "double precision", "money"] {
1465 assert!(is_numeric_pg_type(t), "{t} should count as numeric");
1466 }
1467 for t in ["text", "uuid", "timestamptz", "boolean", "jsonb", "USER-DEFINED"] {
1468 assert!(!is_numeric_pg_type(t), "{t} must not accept a SUM");
1469 }
1470 }
1471}
1472
1473#[cfg(test)]
1474mod filter_cast_tests {
1475 use super::*;
1476
1477 #[test]
1478 fn typed_base_columns_get_their_own_cast() {
1479 for (t, want) in [
1480 ("boolean", "boolean"),
1481 ("integer", "integer"),
1482 ("bigint", "bigint"),
1483 ("smallint", "smallint"),
1484 ("numeric", "numeric"),
1485 ("double precision", "double precision"),
1486 ("uuid", "uuid"),
1487 ("date", "date"),
1488 ("time without time zone", "time"),
1489 ("timestamp with time zone", "timestamptz"),
1490 ("timestamp without time zone", "timestamp"),
1491 ] {
1492 assert_eq!(filter_cast_for(t, "b").as_deref(), Some(want), "{t}");
1493 }
1494 }
1495
1496 #[test]
1497 fn text_like_and_composite_columns_keep_the_text_bind() {
1498 for t in ["text", "character varying", "character", "jsonb", "json", "bytea", "text[]", "uuid[]"] {
1499 assert_eq!(filter_cast_for(t, "b"), None, "{t}");
1500 }
1501 assert_eq!(filter_cast_for("approvals.money_amount", "d"), None);
1503 assert_eq!(filter_cast_for("approvals.address", "c"), None);
1504 }
1505
1506 #[test]
1507 fn an_enum_casts_to_its_catalog_name() {
1508 assert_eq!(filter_cast_for("approval_status", "e").as_deref(), Some("approval_status"));
1509 assert_eq!(
1510 filter_cast_for("recruitment.stage_kind", "e").as_deref(),
1511 Some("recruitment.stage_kind")
1512 );
1513 }
1514
1515 #[test]
1516 fn a_generated_hint_wins_over_the_catalog_for_its_column() {
1517 let hints: HashMap<String, String> =
1518 [("id", "uuid"), ("status", "approval_status")].iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
1519 let catalog: HashMap<String, String> = [
1520 ("id", "uuid"),
1521 ("status", "approvals.approval_status"),
1522 ("folded", "boolean"),
1523 ("requested_by", "uuid"),
1524 ]
1525 .iter()
1526 .map(|(k, v)| (k.to_string(), v.to_string()))
1527 .collect();
1528 let merged = merge_filter_casts(&hints, catalog);
1529 assert_eq!(merged["status"], "approvals.approval_status");
1532 assert_eq!(merged["id"], "uuid");
1533 assert_eq!(merged["folded"], "boolean");
1534 assert_eq!(merged["requested_by"], "uuid");
1535 assert_eq!(merged.len(), 4);
1536 }
1537
1538 #[test]
1539 fn a_hint_for_another_type_still_decides_its_column() {
1540 let hints: HashMap<String, String> =
1541 [("amount", "numeric")].iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
1542 let catalog: HashMap<String, String> =
1543 [("amount", "real")].iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
1544 assert_eq!(merge_filter_casts(&hints, catalog)["amount"], "numeric");
1545 }
1546
1547 #[test]
1548 fn a_filter_on_a_bare_enum_hint_reads_the_catalog_to_qualify_it() {
1549 let hints: HashMap<String, String> = [("id", "uuid"), ("status", "task_status")]
1550 .iter()
1551 .map(|(k, v)| (k.to_string(), v.to_string()))
1552 .collect();
1553 let needs = |pairs: &[(&str, &str)]| {
1554 let f: HashMap<String, String> =
1555 pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
1556 parse_query_filter(&f, &hints, None).unwrap().needs_catalog_types()
1557 };
1558 assert!(needs(&[("status[in]", "done,skipped")]));
1559 assert!(needs(&[("status[eq]", "done")]));
1560 assert!(!needs(&[("id[in]", "a,b")]));
1562 }
1563
1564 #[test]
1565 fn a_sort_on_an_enum_casts_to_its_schema_qualified_name() {
1566 assert_eq!(
1567 cast_suffix("USER-DEFINED", "task_status", "lifecycle").as_deref(),
1568 Some("lifecycle.task_status")
1569 );
1570 assert_eq!(cast_suffix("USER-DEFINED", "mood", "public").as_deref(), Some("mood"));
1571 assert_eq!(cast_suffix("uuid", "uuid", "pg_catalog").as_deref(), Some("uuid"));
1572 }
1573
1574 #[test]
1575 fn only_a_filter_with_an_uncast_comparison_reads_the_catalog() {
1576 let hints: HashMap<String, String> =
1577 [("id", "uuid")].iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
1578 let parse = |pairs: &[(&str, &str)]| {
1579 let f: HashMap<String, String> =
1580 pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
1581 parse_query_filter(&f, &hints, None).unwrap().has_uncast_value_conditions()
1582 };
1583 assert!(!parse(&[("id[in]", "a,b"), ("name[contain]", "x"), ("limit", "5")]));
1584 assert!(!parse(&[("deleted_by[isnull]", "1")]));
1585 assert!(parse(&[("folded[eq]", "false")]));
1586 assert!(parse(&[("folded", "false")]));
1587 assert!(parse(&[("scheduled_at[between]", "2026-10-01,2026-10-03")]));
1588 assert!(parse(&[("sequence[or]", "3")]));
1589 }
1590}
1591
1592#[cfg(test)]
1593mod keyset_predicate_tests {
1594 use super::and_keyset_predicate;
1595
1596 #[test]
1597 fn a_client_or_cannot_escape_the_keyset_window() {
1598 assert_eq!(
1599 and_keyset_predicate(" WHERE kind = $1 OR status = $2", "id > $3"),
1600 " WHERE (kind = $1 OR status = $2) AND (id > $3)"
1601 );
1602 }
1603
1604 #[test]
1605 fn no_filter_leaves_the_keyset_alone() {
1606 assert_eq!(and_keyset_predicate("", "id > $1"), " WHERE id > $1");
1607 }
1608}
1609
1610#[cfg(test)]
1611mod changed_columns_tests {
1612 use super::changed_columns;
1613
1614 #[derive(serde::Serialize)]
1615 struct Row {
1616 id: &'static str,
1617 notes: &'static str,
1618 status: &'static str,
1619 #[serde(skip_serializing_if = "Option::is_none")]
1620 label: Option<&'static str>,
1621 }
1622
1623 #[test]
1624 fn only_the_fields_that_differ_are_changed() {
1625 let before = Row {
1626 id: "1",
1627 notes: "a",
1628 status: "draft",
1629 label: None,
1630 };
1631 let after = Row {
1632 id: "2",
1633 notes: "b",
1634 status: "draft",
1635 label: Some("x"),
1636 };
1637 assert_eq!(
1638 changed_columns(&before, &after).unwrap(),
1639 vec!["label".to_string(), "notes".to_string()]
1640 );
1641 assert!(changed_columns(&before, &before).unwrap().is_empty());
1642 }
1643}