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 listed = async {
295 let mut query_filter = self.parse_typed_filters(filters, column_types, None).await?;
297 if let Some(cond) = base_condition {
298 query_filter.add_base_condition(cond);
299 }
300
301 if !search_fields.is_empty() {
303 query_filter.search_fields = search_fields.iter().map(|s| s.to_string()).collect();
304 }
305
306 self.execute_list(pagination, query_filter).await
307 }
308 .await;
309 match listed {
310 Ok(page) => Ok(page),
311 Err(e) => Err(self.name_refused_parameter(e, filters).await),
312 }
313 }
314
315 pub async fn list_with_filters_whitelisted(
347 &self,
348 pagination: PaginationParams,
349 filters: &HashMap<String, String>,
350 column_types: &HashMap<String, String>,
351 search_fields: &[&str],
352 allowed_fields: Option<&HashSet<String>>,
353 ) -> anyhow::Result<PaginatedResult<T>>
354 where
355 T: Send + Sync,
356 {
357 let listed = async {
358 let mut query_filter =
360 self.parse_typed_filters(filters, column_types, allowed_fields).await?;
361
362 if !search_fields.is_empty() {
364 query_filter.search_fields = search_fields.iter().map(|s| s.to_string()).collect();
365 }
366
367 self.execute_list(pagination, query_filter).await
368 }
369 .await;
370 match listed {
371 Ok(page) => Ok(page),
372 Err(e) => Err(self.name_refused_parameter(e, filters).await),
373 }
374 }
375
376 pub async fn update_guarded(
388 &self,
389 id: &str,
390 entity: &T,
391 only: Option<&[String]>,
392 guard: &str,
393 ) -> anyhow::Result<Option<T>>
394 where
395 T: Serialize + Send + Sync,
396 {
397 let result = match update_statement(&self.table_name, entity, only, guard)? {
398 UpdateStatement::Unchanged(query) => {
399 crate::company_scope::fetch_optional_scoped(
400 &self.pool,
401 sqlx::query_as::<_, T>(&query).bind(id),
402 )
403 .await?
404 }
405 UpdateStatement::Update { query, json } => {
406 crate::company_scope::fetch_optional_scoped(
407 &self.pool,
408 sqlx::query_as::<_, T>(&query).bind(json).bind(id),
409 )
410 .await?
411 }
412 };
413
414 Ok(result)
415 }
416
417 async fn parse_typed_filters(
432 &self,
433 filters: &HashMap<String, String>,
434 column_types: &HashMap<String, String>,
435 allowed_fields: Option<&HashSet<String>>,
436 ) -> anyhow::Result<crate::QueryFilter> {
437 let query_filter = parse_query_filter(filters, column_types, allowed_fields)?;
438 if !query_filter.needs_catalog_types() {
439 return Ok(query_filter);
440 }
441 let catalog = catalog_filter_casts(&self.pool, &self.table_name).await?;
442 let merged = merge_filter_casts(column_types, catalog);
443 parse_query_filter(filters, &merged, allowed_fields)
444 }
445
446 #[allow(clippy::type_complexity)]
460 async fn execute_list(
461 &self,
462 pagination: PaginationParams,
463 query_filter: crate::QueryFilter,
464 ) -> anyhow::Result<PaginatedResult<T>> {
465 let limit = pagination.limit() as i64;
466 let backwards =
467 query_filter.cursor_before.is_some() && query_filter.cursor_after.is_none();
468 let cursor_walk = query_filter.cursor_after.is_some() || backwards;
469
470 let (mut where_clause, mut filter_params) = query_filter.build_where_clause();
471 let order_clause;
472 let mut boundary_casts: Vec<Option<String>> = Vec::new();
474
475 let sorts = total_order(
481 query_filter
482 .sorts
483 .iter()
484 .map(|s| (s.field.clone(), s.direction.clone()))
485 .collect(),
486 );
487
488 if cursor_walk || sorts.iter().any(|(f, _)| f != "id") {
494 boundary_casts = self.sort_column_casts(&sorts).await?;
495 }
496
497 if cursor_walk {
498 let opaque = if backwards {
499 query_filter.cursor_before.clone().unwrap()
500 } else {
501 query_filter.cursor_after.clone().unwrap()
502 };
503 let payload = crate::filter::cursor::decode_cursor(&opaque, &sorts).map_err(|e| {
504 let parameter = if backwards { "before" } else { "after" };
507 let why = match e {
508 crate::filter::CursorError::SortMismatch { .. } => {
509 "it was issued for a different sort order"
510 }
511 crate::filter::CursorError::Version(_) => "it was issued by an older version",
512 _ => "it is not a position this list issued",
513 };
514 anyhow::Error::from(crate::filter::QueryParamError::new(
515 parameter,
516 crate::filter::QueryParamReason::InvalidCursor,
517 format!("`{parameter}` cannot continue this list: {why}"),
518 ))
519 })?;
520 let mut idx = filter_params.len() + 1;
521 let (keyset_sql, keyset_params) = crate::filter::cursor::build_keyset_predicate(
522 &payload,
523 &mut idx,
524 &boundary_casts,
525 backwards,
526 );
527 where_clause = and_keyset_predicate(&where_clause, &keyset_sql);
528 filter_params.extend(keyset_params);
529 order_clause = order_by_sql(&sorts, backwards);
530 } else {
531 order_clause = order_by_sql(&sorts, false);
533 }
534 let boundary_sorts: Vec<(String, FilterSortDirection)> = sorts;
536
537 let (total, count_mode) = if query_filter.estimate_total {
543 let estimate = self.estimate_filtered_rows(&where_clause, &filter_params).await?;
544 if estimate <= EXACT_COUNT_BELOW {
545 (self.count_filtered_rows(&where_clause, &filter_params).await?, "exact")
546 } else {
547 (estimate, "estimate")
548 }
549 } else if cursor_walk {
550 (0u64, "none")
551 } else {
552 (self.count_filtered_rows(&where_clause, &filter_params).await?, "exact")
553 };
554
555 let fetch = limit + 1;
558 let data_query = if cursor_walk {
559 format!(
560 "SELECT * FROM {}{}{} LIMIT {}",
561 self.table_name, where_clause, order_clause, fetch
562 )
563 } else {
564 format!(
565 "SELECT * FROM {}{}{} LIMIT {} OFFSET {}",
566 self.table_name,
567 where_clause,
568 order_clause,
569 fetch,
570 pagination.offset()
571 )
572 };
573
574 let mut pagination_info = PaginationInfo::new(pagination.page, pagination.per_page, total);
575 pagination_info.count_mode = count_mode.to_string();
576
577 let mut rows_query = sqlx::query(&data_query);
582 for param in &filter_params {
583 rows_query = rows_query.bind(param);
584 }
585 let rows: Vec<PgRow> =
586 crate::company_scope::fetch_all_rows_scoped(&self.pool, rows_query).await?;
587 let has_more = rows.len() as i64 > limit;
588 let mut page: Vec<PgRow> = rows.into_iter().take(limit as usize).collect();
589 if backwards {
590 page.reverse();
591 }
592 let data: anyhow::Result<Vec<T>> = page
593 .iter()
594 .map(|row| T::from_row(row).map_err(|e| anyhow::anyhow!("decode row: {e}")))
595 .collect();
596 let data = data?;
597
598 let deterministic = !boundary_sorts.is_empty();
602 let next_cursor = if (has_more || backwards) && deterministic {
603 page.last().and_then(|r| {
604 let casts = if boundary_casts.is_empty() {
605 &boundary_casts
608 } else {
609 &boundary_casts
610 };
611 self.row_cursor(r, &boundary_sorts, casts)
612 })
613 } else {
614 None
615 };
616 let prev_cursor = if !page.is_empty() && deterministic {
617 page.first().and_then(|r| self.row_cursor(r, &boundary_sorts, &boundary_casts))
618 } else {
619 None
620 };
621
622 pagination_info.has_more = Some(has_more);
623 pagination_info.next_cursor = next_cursor;
624 pagination_info.prev_cursor = prev_cursor;
625
626 Ok(PaginatedResult {
627 data,
628 pagination: pagination_info,
629 })
630 }
631
632 async fn count_filtered_rows(
637 &self,
638 where_clause: &str,
639 filter_params: &[String],
640 ) -> anyhow::Result<u64> {
641 let count_query = format!("SELECT COUNT(*) FROM {}{}", self.table_name, where_clause);
642 let mut count_query_builder = sqlx::query_scalar::<_, i64>(&count_query);
643 for param in filter_params {
644 count_query_builder = count_query_builder.bind(param);
645 }
646 Ok(crate::company_scope::fetch_one_scalar_scoped(&self.pool, count_query_builder).await?
647 as u64)
648 }
649
650 async fn estimate_filtered_rows(
651 &self,
652 where_clause: &str,
653 filter_params: &[String],
654 ) -> anyhow::Result<u64> {
655 let explain = format!("EXPLAIN (FORMAT JSON) SELECT 1 FROM {}{}", self.table_name, where_clause);
656 let mut builder = sqlx::query_scalar::<_, serde_json::Value>(&explain);
657 for param in filter_params {
658 builder = builder.bind(param);
659 }
660 let plan: serde_json::Value =
661 crate::company_scope::fetch_one_scalar_scoped(&self.pool, builder).await?;
662 let rows = plan
663 .as_array()
664 .and_then(|a| a.first())
665 .and_then(|top| top.get("Plan"))
666 .and_then(|p| p.get("Plan Rows"))
667 .and_then(|r| r.as_i64())
668 .unwrap_or(0);
669 Ok(rows.max(0) as u64)
670 }
671
672 async fn sort_column_casts(
677 &self,
678 sorts: &[(String, FilterSortDirection)],
679 ) -> anyhow::Result<Vec<Option<String>>> {
680 let (schema, table) = match self.table_name.rsplit_once('.') {
681 Some((s, t)) => (s.to_string(), t.to_string()),
682 None => ("public".to_string(), self.table_name.clone()),
683 };
684 let mut casts: Vec<Option<String>> = Vec::with_capacity(sorts.len());
685 for (field, _) in sorts {
686 let row: Option<(String, String, String)> = sqlx::query_as(
687 "SELECT data_type, coalesce(udt_name, ''), coalesce(udt_schema, '') \
688 FROM information_schema.columns \
689 WHERE table_schema = $1 AND table_name = $2 AND column_name = $3",
690 )
691 .bind(&schema)
692 .bind(&table)
693 .bind(field)
694 .fetch_optional(&self.pool)
695 .await?;
696 let cast = row
697 .map(|(data_type, udt, udt_schema)| cast_suffix(&data_type, &udt, &udt_schema))
698 .flatten();
699 casts.push(cast);
700 }
701 Ok(casts)
702 }
703
704 fn row_cursor(
708 &self,
709 row: &PgRow,
710 sorts: &[(String, FilterSortDirection)],
711 casts: &[Option<String>],
712 ) -> Option<String> {
713 let id: uuid::Uuid = row.try_get("id").ok()?;
714 let mut values: Vec<serde_json::Value> = Vec::with_capacity(sorts.len());
715 for (i, (field, _)) in sorts.iter().enumerate() {
716 let field = field.as_str();
717 let mut data_type = casts.get(i).and_then(|c| c.as_deref()).unwrap_or("");
718 if field == "id" && data_type.is_empty() {
722 data_type = "uuid";
723 }
724 let text: Option<String> = match data_type {
725 "numeric" => row
726 .try_get::<Option<sqlx::types::Decimal>, _>(field)
727 .ok()?
728 .map(|d| d.to_string()),
729 "uuid" => row
730 .try_get::<Option<uuid::Uuid>, _>(field)
731 .ok()?
732 .map(|u| u.to_string()),
733 "timestamptz" => row
734 .try_get::<Option<chrono::DateTime<chrono::Utc>>, _>(field)
735 .ok()?
736 .map(|t| t.to_rfc3339()),
737 "integer" | "smallint" => row
738 .try_get::<Option<i32>, _>(field)
739 .ok()?
740 .map(|n| n.to_string()),
741 "bigint" => row
742 .try_get::<Option<i64>, _>(field)
743 .ok()?
744 .map(|n| n.to_string()),
745 "boolean" => row
746 .try_get::<Option<bool>, _>(field)
747 .ok()?
748 .map(|b| b.to_string()),
749 "date" => row
750 .try_get::<Option<chrono::NaiveDate>, _>(field)
751 .ok()?
752 .map(|d| d.to_string()),
753 "timestamp" => row
754 .try_get::<Option<chrono::NaiveDateTime>, _>(field)
755 .ok()?
756 .map(|t| t.format("%Y-%m-%d %H:%M:%S%.6f").to_string()),
757 "" => row.try_get::<Option<String>, _>(field).ok()?,
759 _ => row.try_get_unchecked::<Option<String>, _>(field).ok()?,
764 };
765 values.push(serde_json::Value::String(text?));
766 }
767 crate::filter::cursor::encode_cursor(sorts, &values, &id.to_string()).ok()
768 }
769}
770
771pub(crate) enum UpdateStatement {
775 Unchanged(String),
777 Update { query: String, json: String },
779}
780
781pub(crate) fn update_statement<T: Serialize>(
783 table: &str,
784 entity: &T,
785 only: Option<&[String]>,
786 guard: &str,
787) -> anyhow::Result<UpdateStatement> {
788 let json_obj = match serde_json::to_value(entity)? {
789 Value::Object(obj) => obj,
790 _ => return Err(anyhow::anyhow!("Entity must serialize to a JSON object")),
791 };
792
793 let update_columns: Vec<&String> = json_obj
794 .keys()
795 .filter(|k| *k != "id")
796 .filter(|k| only.is_none_or(|cols| cols.iter().any(|c| c == *k)))
797 .collect();
798
799 if update_columns.is_empty() {
800 return Ok(UpdateStatement::Unchanged(format!(
801 "SELECT t.* FROM {table} AS t WHERE t.id = $1::uuid{guard}"
802 )));
803 }
804
805 let column_names = update_columns
806 .iter()
807 .map(|k| quote_ident(k))
808 .collect::<Vec<_>>()
809 .join(", ");
810 let json = serde_json::to_string(&json_obj)?;
811
812 let query = format!(
815 r#"
816 WITH new_row AS (
817 SELECT (jsonb_populate_record(NULL::{table}, $1::jsonb)).*
818 )
819 UPDATE {table} AS t
820 SET ({columns}) = (SELECT {columns} FROM new_row)
821 WHERE t.id = $2::uuid{guard}
822 RETURNING t.*
823 "#,
824 table = table,
825 columns = column_names,
826 guard = guard,
827 );
828 Ok(UpdateStatement::Update { query, json })
829}
830
831pub(crate) fn insert_statement<T: Serialize>(
834 table: &str,
835 entity: &T,
836) -> anyhow::Result<(String, Option<String>)> {
837 let json_obj = match serde_json::to_value(entity)? {
838 Value::Object(obj) => obj,
839 _ => return Err(anyhow::anyhow!("Entity must serialize to a JSON object")),
840 };
841
842 let insert_columns: Vec<String> = json_obj.keys().map(|k| quote_ident(k)).collect();
856
857 if insert_columns.is_empty() {
858 return Ok((format!("INSERT INTO {table} DEFAULT VALUES RETURNING *"), None));
861 }
862 let columns = insert_columns.join(", ");
863 let query = format!(
864 r#"
865 INSERT INTO {table} ({columns})
866 SELECT {columns} FROM jsonb_populate_record(NULL::{table}, $1::jsonb)
867 RETURNING *
868 "#,
869 );
870 Ok((query, Some(serde_json::to_string(&json_obj)?)))
871}
872
873pub fn changed_columns<T: Serialize>(before: &T, after: &T) -> anyhow::Result<Vec<String>> {
878 let (Value::Object(before), Value::Object(after)) =
879 (serde_json::to_value(before)?, serde_json::to_value(after)?)
880 else {
881 return Err(anyhow::anyhow!("Entity must serialize to a JSON object"));
882 };
883 Ok(after
884 .into_iter()
885 .filter(|(k, v)| k != "id" && before.get(k) != Some(v))
886 .map(|(k, _)| k)
887 .collect())
888}
889
890const KEY_COLUMN: &str = "id";
893
894pub(crate) fn total_order(
908 mut sorts: Vec<(String, FilterSortDirection)>,
909) -> Vec<(String, FilterSortDirection)> {
910 if !sorts.iter().any(|(f, _)| f == KEY_COLUMN) {
911 sorts.push((KEY_COLUMN.into(), FilterSortDirection::Asc));
912 }
913 sorts
914}
915
916pub(crate) fn order_by_sql(sorts: &[(String, FilterSortDirection)], backwards: bool) -> String {
920 let parts: Vec<String> = sorts
921 .iter()
922 .map(|(f, d)| {
923 let dir = if (*d == FilterSortDirection::Desc) != backwards { "DESC" } else { "ASC" };
924 format!("{} {}", f, dir)
925 })
926 .collect();
927 format!(" ORDER BY {}", parts.join(", "))
928}
929
930fn and_keyset_predicate(where_clause: &str, keyset_sql: &str) -> String {
934 match where_clause.trim_start().strip_prefix("WHERE ") {
935 None => format!(" WHERE {}", keyset_sql),
936 Some(existing) => format!(" WHERE ({}) AND ({})", existing, keyset_sql),
937 }
938}
939
940fn cast_suffix(data_type: &str, udt_name: &str, udt_schema: &str) -> Option<String> {
941 match data_type {
942 "uuid" => Some("uuid".into()),
943 "numeric" => Some("numeric".into()),
944 "integer" => Some("integer".into()),
945 "smallint" => Some("smallint".into()),
946 "bigint" => Some("bigint".into()),
947 "boolean" => Some("boolean".into()),
948 "date" => Some("date".into()),
949 "timestamp with time zone" => Some("timestamptz".into()),
950 "timestamp without time zone" => Some("timestamp".into()),
951 "USER-DEFINED" if !udt_name.is_empty() => Some(match udt_schema {
955 "" | "public" | "pg_catalog" => udt_name.to_string(),
956 schema => format!("{schema}.{udt_name}"),
957 }),
958 _ => None,
959 }
960}
961
962fn filter_cast_for(type_name: &str, typtype: &str) -> Option<String> {
967 match typtype {
968 "e" => Some(type_name.to_string()),
971 "b" => match type_name {
972 "uuid" | "boolean" | "smallint" | "integer" | "bigint" | "numeric" | "real"
973 | "double precision" | "date" | "interval" | "inet" | "cidr" | "macaddr" => {
974 Some(type_name.to_string())
975 }
976 "time without time zone" => Some("time".into()),
977 "time with time zone" => Some("timetz".into()),
978 "timestamp with time zone" => Some("timestamptz".into()),
979 "timestamp without time zone" => Some("timestamp".into()),
980 _ => None,
981 },
982 _ => None,
983 }
984}
985
986async fn catalog_filter_casts(
993 pool: &PgPool,
994 qualified_table: &str,
995) -> anyhow::Result<HashMap<String, String>> {
996 let q = sqlx::query_as::<Postgres, (String, String, String)>(
997 "SELECT a.attname::text, format_type(a.atttypid, NULL), t.typtype::text
998 FROM pg_catalog.pg_attribute a
999 JOIN pg_catalog.pg_type t ON t.oid = a.atttypid
1000 WHERE a.attrelid = to_regclass($1) AND a.attnum > 0 AND NOT a.attisdropped",
1001 )
1002 .bind(qualified_table.to_string());
1003 let rows = crate::company_scope::fetch_all_scoped(pool, q).await?;
1004 Ok(rows
1005 .into_iter()
1006 .filter_map(|(column, type_name, typtype)| {
1007 filter_cast_for(&type_name, &typtype).map(|cast| (column, cast))
1008 })
1009 .collect())
1010}
1011
1012fn merge_filter_casts(
1017 hints: &HashMap<String, String>,
1018 mut catalog: HashMap<String, String>,
1019) -> HashMap<String, String> {
1020 for (column, cast) in hints {
1021 let qualified_same_type = catalog
1022 .get(column)
1023 .and_then(|c| c.rsplit_once('.'))
1024 .is_some_and(|(_, name)| name.trim_matches('"') == cast.as_str());
1025 if !qualified_same_type {
1026 catalog.insert(column.clone(), cast.clone());
1027 }
1028 }
1029 catalog
1030}
1031
1032pub(crate) fn explain_unknown_column(error: sqlx::Error, table: &str) -> anyhow::Error {
1040 let text = error.to_string();
1041 if text.contains("does not exist") && text.contains("column") {
1042 return anyhow::Error::new(error).context(format!(
1043 "insert into {table} named a column that does not exist: the entity serializes a field \
1044 with no matching column. Every serialized field must be a column of the table (rename \
1045 it, map it with #[serde(rename)], or skip it with #[serde(skip)])"
1046 ));
1047 }
1048 anyhow::Error::new(error)
1049}
1050
1051fn quote_ident(name: &str) -> String {
1058 format!("\"{}\"", name.replace('"', "\"\""))
1059}
1060
1061#[async_trait]
1062impl<T> DatabaseOperations<T> for PostgresRepository<T>
1063where
1064 T: for<'a> FromRow<'a, PgRow> + Send + Sync + Unpin + Serialize,
1065{
1066 async fn create(&self, entity: &T) -> anyhow::Result<T> {
1067 let (query, json) = insert_statement(&self.table_name, entity)?;
1068 let statement = match json {
1070 Some(json) => sqlx::query_as::<_, T>(&query).bind(json),
1071 None => sqlx::query_as::<_, T>(&query),
1072 };
1073 let result = crate::company_scope::fetch_one_scoped(&self.pool, statement)
1074 .await
1075 .map_err(|e| explain_unknown_column(e, &self.table_name))?;
1076
1077 Ok(result)
1078 }
1079
1080 async fn find_by_id(&self, id: &str) -> anyhow::Result<Option<T>> {
1081 let query = format!("SELECT * FROM {} WHERE id = $1::uuid", self.table_name);
1083 let result = crate::company_scope::fetch_optional_scoped(
1084 &self.pool,
1085 sqlx::query_as::<Postgres, T>(&query).bind(id),
1086 )
1087 .await?;
1088 Ok(result)
1089 }
1090
1091 async fn find_all(&self) -> anyhow::Result<Vec<T>> {
1092 let query = format!("SELECT * FROM {}", self.table_name);
1093 let results = crate::company_scope::fetch_all_scoped(
1094 &self.pool,
1095 sqlx::query_as::<Postgres, T>(&query),
1096 )
1097 .await?;
1098 Ok(results)
1099 }
1100
1101 async fn update(&self, id: &str, entity: &T) -> anyhow::Result<Option<T>> {
1102 self.update_guarded(id, entity, None, "").await
1103 }
1104
1105 async fn delete(&self, id: &str) -> anyhow::Result<bool> {
1106 let query = format!("DELETE FROM {} WHERE id = $1::uuid", self.table_name);
1107 let result = crate::company_scope::execute_scoped(
1108 &self.pool,
1109 sqlx::query(&query).bind(id),
1110 )
1111 .await?;
1112 Ok(result.rows_affected() > 0)
1113 }
1114
1115 async fn count(&self) -> anyhow::Result<u64> {
1116 let query = format!("SELECT COUNT(*) FROM {}", self.table_name);
1117 let count = crate::company_scope::fetch_one_scalar_scoped(
1118 &self.pool,
1119 sqlx::query_scalar::<_, i64>(&query),
1120 )
1121 .await? as u64;
1122 Ok(count)
1123 }
1124
1125 async fn exists(&self, id: &str) -> anyhow::Result<bool> {
1126 let query = format!("SELECT 1 FROM {} WHERE id = $1::uuid LIMIT 1", self.table_name);
1127 let result = crate::company_scope::fetch_optional_scalar_scoped(
1128 &self.pool,
1129 sqlx::query_scalar::<_, i32>(&query).bind(id),
1130 )
1131 .await?;
1132 Ok(result.is_some())
1133 }
1134
1135 async fn execute_query(&self, query: &str) -> anyhow::Result<u64> {
1136 let result = crate::company_scope::execute_scoped(
1137 &self.pool,
1138 sqlx::query(query),
1139 )
1140 .await?;
1141 Ok(result.rows_affected())
1142 }
1143}
1144
1145#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1149pub enum AggregateFn {
1150 Sum,
1151 Avg,
1152 Min,
1153 Max,
1154}
1155
1156impl AggregateFn {
1157 fn sql(self) -> &'static str {
1158 match self {
1159 AggregateFn::Sum => "SUM",
1160 AggregateFn::Avg => "AVG",
1161 AggregateFn::Min => "MIN",
1162 AggregateFn::Max => "MAX",
1163 }
1164 }
1165
1166 fn label(self) -> &'static str {
1167 match self {
1168 AggregateFn::Sum => "sum",
1169 AggregateFn::Avg => "avg",
1170 AggregateFn::Min => "min",
1171 AggregateFn::Max => "max",
1172 }
1173 }
1174
1175 fn requires_numeric(self) -> bool {
1178 matches!(self, AggregateFn::Sum | AggregateFn::Avg)
1179 }
1180}
1181
1182#[derive(Debug, Clone, Default)]
1184pub struct AggregateSpec {
1185 pub group_by: Option<String>,
1187 pub reductions: Vec<(AggregateFn, String)>,
1189 pub group_limit: usize,
1191 pub label_field: Option<String>,
1195 pub label_relation: Option<(String, String)>,
1199}
1200
1201pub const DEFAULT_GROUP_LIMIT: usize = 200;
1209
1210#[derive(Debug, Clone, Serialize, Deserialize)]
1213pub struct AggregateGroup {
1214 pub key: Option<String>,
1215 pub label: Option<String>,
1218 pub count: u64,
1219 pub values: HashMap<String, Option<String>>,
1227}
1228
1229#[derive(Debug, Clone, Serialize, Deserialize)]
1231pub struct AggregateResult {
1232 pub groups: Vec<AggregateGroup>,
1233 pub total: AggregateGroup,
1234 pub truncated: bool,
1236}
1237
1238#[derive(Debug, Clone)]
1241pub struct AggregateFieldError(pub String);
1242
1243impl std::fmt::Display for AggregateFieldError {
1244 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1245 f.write_str(&self.0)
1246 }
1247}
1248
1249impl std::error::Error for AggregateFieldError {}
1250
1251fn is_numeric_pg_type(pg_type: &str) -> bool {
1253 let t = pg_type.trim().to_ascii_lowercase();
1254 let t = t.split('(').next().unwrap_or(&t).trim();
1255 matches!(
1256 t,
1257 "numeric" | "decimal" | "money"
1258 | "smallint" | "int2" | "integer" | "int" | "int4" | "bigint" | "int8"
1259 | "real" | "float4" | "double precision" | "float8"
1260 | "smallserial" | "serial" | "bigserial"
1261 )
1262}
1263
1264async fn catalog_columns(
1273 pool: &PgPool,
1274 qualified_table: &str,
1275) -> anyhow::Result<HashMap<String, String>> {
1276 let (schema, table) = match qualified_table.split_once('.') {
1277 Some((s, t)) => (s.to_string(), t.to_string()),
1278 None => ("public".to_string(), qualified_table.to_string()),
1279 };
1280 let q = sqlx::query_as::<Postgres, (String, String)>(
1281 "SELECT column_name, data_type FROM information_schema.columns
1282 WHERE table_schema = $1 AND table_name = $2",
1283 )
1284 .bind(schema)
1285 .bind(table);
1286 let rows = crate::company_scope::fetch_all_scoped(pool, q).await?;
1287 Ok(rows.into_iter().collect())
1288}
1289
1290fn resolve_column<'a>(
1299 name: &str,
1300 column_types: &'a HashMap<String, String>,
1301) -> Result<(&'a str, &'a str), AggregateFieldError> {
1302 column_types
1303 .get_key_value(name)
1304 .map(|(k, v)| (k.as_str(), v.as_str()))
1305 .ok_or_else(|| {
1306 AggregateFieldError(format!("unknown field `{name}` — not a column of this entity"))
1307 })
1308}
1309
1310impl<T: for<'a> FromRow<'a, PgRow> + Send + Unpin> PostgresRepository<T> {
1311 pub async fn aggregate_with_filters(
1319 &self,
1320 spec: &AggregateSpec,
1321 filters: &HashMap<String, String>,
1322 column_types: &HashMap<String, String>,
1323 search_fields: &[&str],
1324 ) -> anyhow::Result<AggregateResult> {
1325 self.aggregate_with_filters_and_base(spec, None, filters, column_types, search_fields)
1326 .await
1327 }
1328
1329 pub(crate) async fn aggregate_with_filters_and_base(
1333 &self,
1334 spec: &AggregateSpec,
1335 base_condition: Option<&str>,
1336 filters: &HashMap<String, String>,
1337 column_types: &HashMap<String, String>,
1338 search_fields: &[&str],
1339 ) -> anyhow::Result<AggregateResult> {
1340 match self
1341 .aggregate_unattributed(spec, base_condition, filters, column_types, search_fields)
1342 .await
1343 {
1344 Ok(result) => Ok(result),
1345 Err(e) => Err(self.name_refused_parameter(e, filters).await),
1346 }
1347 }
1348
1349 async fn name_refused_parameter(
1355 &self,
1356 err: anyhow::Error,
1357 filters: &HashMap<String, String>,
1358 ) -> anyhow::Error {
1359 let err = crate::filter::attribute_query_error(err, filters);
1360 if crate::filter::undefined_operator(&err).is_none() {
1361 return err;
1362 }
1363 let (schema, table) = match self.table_name.split_once('.') {
1364 Some((s, t)) => (s.to_string(), t.to_string()),
1365 None => ("public".to_string(), self.table_name.clone()),
1366 };
1367 let types: Vec<(String, String)> = sqlx::query_as(
1368 "SELECT column_name, data_type FROM information_schema.columns \
1369 WHERE table_schema = $1 AND table_name = $2",
1370 )
1371 .bind(schema)
1372 .bind(table)
1373 .fetch_all(&self.pool)
1374 .await
1375 .unwrap_or_default();
1376 let types: HashMap<String, String> = types.into_iter().collect();
1377 crate::filter::attribute_operator_error(err, filters, &types)
1378 }
1379
1380 async fn aggregate_unattributed(
1383 &self,
1384 spec: &AggregateSpec,
1385 base_condition: Option<&str>,
1386 filters: &HashMap<String, String>,
1387 column_types: &HashMap<String, String>,
1388 search_fields: &[&str],
1389 ) -> anyhow::Result<AggregateResult> {
1390 let mut query_filter = self.parse_typed_filters(filters, column_types, None).await?;
1391 if let Some(cond) = base_condition {
1392 query_filter.add_base_condition(cond);
1393 }
1394 if !search_fields.is_empty() {
1395 query_filter.search_fields = search_fields.iter().map(|s| s.to_string()).collect();
1396 }
1397 query_filter.limit = None;
1400 query_filter.offset = None;
1401 let (where_clause, filter_params) = query_filter.build_where_clause();
1402
1403 let columns = catalog_columns(&self.pool, &self.table_name).await?;
1405 let mut selects: Vec<String> = Vec::new();
1406 let mut value_keys: Vec<String> = Vec::new();
1407 for (func, field) in &spec.reductions {
1408 let (column, pg_type) = resolve_column(field, &columns)
1409 .map_err(|_| crate::filter::QueryParamError::unknown_column(func.label(), field))?;
1410 if func.requires_numeric() && !is_numeric_pg_type(pg_type) {
1411 return Err(crate::filter::QueryParamError::new(
1412 func.label(),
1413 crate::filter::QueryParamReason::NotNumeric,
1414 format!(
1415 "`{label}`: `{field}` is not a numeric field, and {label} needs one",
1416 label = func.label()
1417 ),
1418 )
1419 .into());
1420 }
1421 let key = format!("{}:{}", func.label(), column);
1422 selects.push(format!("{}({})::text AS \"{}\"", func.sql(), column, key));
1425 value_keys.push(key);
1426 }
1427
1428 let group_limit = if spec.group_limit == 0 { DEFAULT_GROUP_LIMIT } else { spec.group_limit };
1429 let reductions = if selects.is_empty() { String::new() } else { format!(", {}", selects.join(", ")) };
1430
1431 let mut label_select = String::new();
1437 let mut label_join = String::new();
1438 if let (Some(field), Some(label_field), Some((rel_table, base_fk))) = (
1439 &spec.group_by,
1440 &spec.label_field,
1441 &spec.label_relation,
1442 ) {
1443 let _ = field;
1444 let qualified = qualify_relation_table(&self.table_name, rel_table);
1445 let rel_columns = catalog_columns(&self.pool, &qualified).await?;
1446 let (label_col, _) = resolve_column(label_field, &rel_columns).map_err(|_| {
1447 crate::filter::QueryParamError::new(
1448 "group_label",
1449 crate::filter::QueryParamReason::UnknownColumn,
1450 format!("`group_label`: `{label_field}` is not a field of the records the groups point at"),
1451 )
1452 })?;
1453 label_select = format!(", (label_rel.{label_col})::text AS __group_label");
1454 label_join = format!(
1455 " LEFT JOIN {qualified} AS label_rel ON label_rel.id IS NOT DISTINCT FROM {base_fk}"
1456 );
1457 }
1458
1459 let sql = match &spec.group_by {
1460 Some(field) => {
1461 let (column, _) = resolve_column(field, &columns).map_err(|_| {
1462 crate::filter::QueryParamError::unknown_column("group_by", field)
1463 })?;
1464 format!(
1465 "SELECT GROUPING({column}) AS __is_total, ({column})::text AS __group_key{label_select}, \
1466 COUNT(*) AS __count{reductions} \
1467 FROM {table}{label_join}{where_clause} \
1468 GROUP BY GROUPING SETS (({column}), ()) \
1469 ORDER BY __is_total DESC, __count DESC, __group_key \
1470 LIMIT {limit}",
1471 column = column,
1472 label_select = label_select,
1473 label_join = label_join,
1474 reductions = reductions,
1475 table = self.table_name,
1476 where_clause = where_clause,
1477 limit = group_limit + 2,
1480 )
1481 }
1482 None => format!(
1483 "SELECT 1 AS __is_total, NULL::text AS __group_key, COUNT(*) AS __count{reductions} \
1484 FROM {table}{where_clause}",
1485 reductions = reductions,
1486 table = self.table_name,
1487 where_clause = where_clause,
1488 ),
1489 };
1490
1491 let mut builder = sqlx::query(&sql);
1492 for param in &filter_params {
1493 builder = builder.bind(param);
1494 }
1495 let rows = crate::company_scope::fetch_all_rows_scoped(&self.pool, builder).await?;
1496
1497 let read_group = |row: &PgRow| -> AggregateGroup {
1498 use sqlx::Row as _;
1499 let mut values = HashMap::with_capacity(value_keys.len());
1500 for key in &value_keys {
1501 values.insert(key.clone(), row.try_get::<Option<String>, _>(key.as_str()).ok().flatten());
1502 }
1503 AggregateGroup {
1504 key: row.try_get::<Option<String>, _>("__group_key").ok().flatten(),
1505 label: row.try_get::<Option<String>, _>("__group_label").ok().flatten(),
1506 count: row.try_get::<i64, _>("__count").unwrap_or(0).max(0) as u64,
1507 values,
1508 }
1509 };
1510
1511 use sqlx::Row as _;
1512 let mut total: Option<AggregateGroup> = None;
1513 let mut groups: Vec<AggregateGroup> = Vec::new();
1514 for row in &rows {
1515 let is_total = row.try_get::<i32, _>("__is_total").unwrap_or(0) == 1;
1516 if is_total {
1517 total = Some(read_group(row));
1519 } else {
1520 groups.push(read_group(row));
1521 }
1522 }
1523
1524 let truncated = groups.len() > group_limit;
1525 groups.truncate(group_limit);
1526
1527 let total = total.unwrap_or_else(|| AggregateGroup {
1530 key: None,
1531 label: None,
1532 count: 0,
1533 values: value_keys.iter().map(|k| (k.clone(), None)).collect(),
1534 });
1535
1536 Ok(AggregateResult { groups, total, truncated })
1537 }
1538}
1539
1540#[cfg(test)]
1541mod aggregate_field_tests {
1542 use super::*;
1543
1544 fn columns() -> HashMap<String, String> {
1545 [
1546 ("status", "text"),
1547 ("total", "numeric"),
1548 ("qty", "integer"),
1549 ("notes", "text"),
1550 ]
1551 .iter()
1552 .map(|(k, v)| (k.to_string(), v.to_string()))
1553 .collect()
1554 }
1555
1556 #[test]
1557 fn resolves_only_declared_columns() {
1558 let cols = columns();
1559 assert_eq!(resolve_column("total", &cols).unwrap().0, "total");
1560 assert!(resolve_column("password_hash", &cols).is_err());
1561 }
1562
1563 #[test]
1566 fn rejects_injection_attempts_rather_than_escaping_them() {
1567 let cols = columns();
1568 for probe in [
1569 "total) FROM selling.sales_orders; DROP TABLE users --",
1570 "status\"",
1571 "1=1",
1572 "total, (SELECT password FROM users)",
1573 "",
1574 ] {
1575 assert!(
1576 resolve_column(probe, &cols).is_err(),
1577 "`{probe}` must be refused, never escaped into the query"
1578 );
1579 }
1580 }
1581
1582 #[test]
1585 fn returns_the_declared_key_not_the_callers_string() {
1586 let cols = columns();
1587 let (name, _) = resolve_column("total", &cols).unwrap();
1588 assert!(std::ptr::eq(name, cols.get_key_value("total").unwrap().0.as_str()));
1589 }
1590
1591 #[test]
1592 fn sum_and_avg_require_a_numeric_type() {
1593 assert!(AggregateFn::Sum.requires_numeric());
1594 assert!(AggregateFn::Avg.requires_numeric());
1595 assert!(!AggregateFn::Min.requires_numeric());
1597 assert!(!AggregateFn::Max.requires_numeric());
1598 }
1599
1600 #[test]
1601 fn recognises_the_numeric_postgres_types() {
1602 for t in ["numeric", "NUMERIC(14,2)", "integer", "bigint", "double precision", "money"] {
1603 assert!(is_numeric_pg_type(t), "{t} should count as numeric");
1604 }
1605 for t in ["text", "uuid", "timestamptz", "boolean", "jsonb", "USER-DEFINED"] {
1606 assert!(!is_numeric_pg_type(t), "{t} must not accept a SUM");
1607 }
1608 }
1609}
1610
1611#[cfg(test)]
1612mod filter_cast_tests {
1613 use super::*;
1614
1615 #[test]
1616 fn typed_base_columns_get_their_own_cast() {
1617 for (t, want) in [
1618 ("boolean", "boolean"),
1619 ("integer", "integer"),
1620 ("bigint", "bigint"),
1621 ("smallint", "smallint"),
1622 ("numeric", "numeric"),
1623 ("double precision", "double precision"),
1624 ("uuid", "uuid"),
1625 ("date", "date"),
1626 ("time without time zone", "time"),
1627 ("timestamp with time zone", "timestamptz"),
1628 ("timestamp without time zone", "timestamp"),
1629 ] {
1630 assert_eq!(filter_cast_for(t, "b").as_deref(), Some(want), "{t}");
1631 }
1632 }
1633
1634 #[test]
1635 fn text_like_and_composite_columns_keep_the_text_bind() {
1636 for t in ["text", "character varying", "character", "jsonb", "json", "bytea", "text[]", "uuid[]"] {
1637 assert_eq!(filter_cast_for(t, "b"), None, "{t}");
1638 }
1639 assert_eq!(filter_cast_for("approvals.money_amount", "d"), None);
1641 assert_eq!(filter_cast_for("approvals.address", "c"), None);
1642 }
1643
1644 #[test]
1645 fn an_enum_casts_to_its_catalog_name() {
1646 assert_eq!(filter_cast_for("approval_status", "e").as_deref(), Some("approval_status"));
1647 assert_eq!(
1648 filter_cast_for("recruitment.stage_kind", "e").as_deref(),
1649 Some("recruitment.stage_kind")
1650 );
1651 }
1652
1653 #[test]
1654 fn a_generated_hint_wins_over_the_catalog_for_its_column() {
1655 let hints: HashMap<String, String> =
1656 [("id", "uuid"), ("status", "approval_status")].iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
1657 let catalog: HashMap<String, String> = [
1658 ("id", "uuid"),
1659 ("status", "approvals.approval_status"),
1660 ("folded", "boolean"),
1661 ("requested_by", "uuid"),
1662 ]
1663 .iter()
1664 .map(|(k, v)| (k.to_string(), v.to_string()))
1665 .collect();
1666 let merged = merge_filter_casts(&hints, catalog);
1667 assert_eq!(merged["status"], "approvals.approval_status");
1670 assert_eq!(merged["id"], "uuid");
1671 assert_eq!(merged["folded"], "boolean");
1672 assert_eq!(merged["requested_by"], "uuid");
1673 assert_eq!(merged.len(), 4);
1674 }
1675
1676 #[test]
1677 fn a_hint_for_another_type_still_decides_its_column() {
1678 let hints: HashMap<String, String> =
1679 [("amount", "numeric")].iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
1680 let catalog: HashMap<String, String> =
1681 [("amount", "real")].iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
1682 assert_eq!(merge_filter_casts(&hints, catalog)["amount"], "numeric");
1683 }
1684
1685 #[test]
1686 fn a_filter_on_a_bare_enum_hint_reads_the_catalog_to_qualify_it() {
1687 let hints: HashMap<String, String> = [("id", "uuid"), ("status", "task_status")]
1688 .iter()
1689 .map(|(k, v)| (k.to_string(), v.to_string()))
1690 .collect();
1691 let needs = |pairs: &[(&str, &str)]| {
1692 let f: HashMap<String, String> =
1693 pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
1694 parse_query_filter(&f, &hints, None).unwrap().needs_catalog_types()
1695 };
1696 assert!(needs(&[("status[in]", "done,skipped")]));
1697 assert!(needs(&[("status[eq]", "done")]));
1698 assert!(!needs(&[("id[in]", "a,b")]));
1700 }
1701
1702 #[test]
1703 fn a_sort_on_an_enum_casts_to_its_schema_qualified_name() {
1704 assert_eq!(
1705 cast_suffix("USER-DEFINED", "task_status", "lifecycle").as_deref(),
1706 Some("lifecycle.task_status")
1707 );
1708 assert_eq!(cast_suffix("USER-DEFINED", "mood", "public").as_deref(), Some("mood"));
1709 assert_eq!(cast_suffix("uuid", "uuid", "pg_catalog").as_deref(), Some("uuid"));
1710 }
1711
1712 #[test]
1713 fn only_a_filter_with_an_uncast_comparison_reads_the_catalog() {
1714 let hints: HashMap<String, String> =
1715 [("id", "uuid")].iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
1716 let parse = |pairs: &[(&str, &str)]| {
1717 let f: HashMap<String, String> =
1718 pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
1719 parse_query_filter(&f, &hints, None).unwrap().has_uncast_value_conditions()
1720 };
1721 assert!(!parse(&[("id[in]", "a,b"), ("name[contain]", "x"), ("limit", "5")]));
1722 assert!(!parse(&[("deleted_by[isnull]", "1")]));
1723 assert!(parse(&[("folded[eq]", "false")]));
1724 assert!(parse(&[("folded", "false")]));
1725 assert!(parse(&[("scheduled_at[between]", "2026-10-01,2026-10-03")]));
1726 assert!(parse(&[("sequence[or]", "3")]));
1727 }
1728}
1729
1730#[cfg(test)]
1731mod keyset_predicate_tests {
1732 use super::and_keyset_predicate;
1733
1734 #[test]
1735 fn a_client_or_cannot_escape_the_keyset_window() {
1736 assert_eq!(
1737 and_keyset_predicate(" WHERE kind = $1 OR status = $2", "id > $3"),
1738 " WHERE (kind = $1 OR status = $2) AND (id > $3)"
1739 );
1740 }
1741
1742 #[test]
1743 fn no_filter_leaves_the_keyset_alone() {
1744 assert_eq!(and_keyset_predicate("", "id > $1"), " WHERE id > $1");
1745 }
1746}
1747
1748#[cfg(test)]
1749mod changed_columns_tests {
1750 use super::changed_columns;
1751
1752 #[derive(serde::Serialize)]
1753 struct Row {
1754 id: &'static str,
1755 notes: &'static str,
1756 status: &'static str,
1757 #[serde(skip_serializing_if = "Option::is_none")]
1758 label: Option<&'static str>,
1759 }
1760
1761 #[test]
1762 fn only_the_fields_that_differ_are_changed() {
1763 let before = Row {
1764 id: "1",
1765 notes: "a",
1766 status: "draft",
1767 label: None,
1768 };
1769 let after = Row {
1770 id: "2",
1771 notes: "b",
1772 status: "draft",
1773 label: Some("x"),
1774 };
1775 assert_eq!(
1776 changed_columns(&before, &after).unwrap(),
1777 vec!["label".to_string(), "notes".to_string()]
1778 );
1779 assert!(changed_columns(&before, &before).unwrap().is_empty());
1780 }
1781}
1782
1783#[cfg(test)]
1784mod total_order_tests {
1785 use super::{order_by_sql, total_order};
1786 use crate::filter::SortDirection::{Asc, Desc};
1787
1788 fn s(f: &str, d: crate::filter::SortDirection) -> (String, crate::filter::SortDirection) {
1789 (f.to_string(), d)
1790 }
1791
1792 #[test]
1793 fn no_sort_orders_by_the_key_ascending() {
1794 assert_eq!(order_by_sql(&total_order(vec![]), false), " ORDER BY id ASC");
1795 }
1796
1797 #[test]
1798 fn a_sort_gets_the_key_as_its_final_tiebreaker() {
1799 let order = total_order(vec![s("status", Desc), s("name", Asc)]);
1800 assert_eq!(order_by_sql(&order, false), " ORDER BY status DESC, name ASC, id ASC");
1801 }
1802
1803 #[test]
1804 fn the_tiebreaker_is_not_repeated_when_the_key_is_already_sorted() {
1805 let last = total_order(vec![s("name", Asc), s("id", Desc)]);
1806 assert_eq!(order_by_sql(&last, false), " ORDER BY name ASC, id DESC");
1807 let first = total_order(vec![s("id", Desc), s("name", Asc)]);
1808 assert_eq!(order_by_sql(&first, false), " ORDER BY id DESC, name ASC");
1809 }
1810
1811 #[test]
1812 fn a_backwards_walk_inverts_every_direction_including_the_key() {
1813 let order = total_order(vec![s("created_at", Desc)]);
1814 assert_eq!(order_by_sql(&order, true), " ORDER BY created_at ASC, id DESC");
1815 }
1816
1817 #[test]
1818 fn the_order_is_idempotent() {
1819 let once = total_order(vec![s("name", Asc)]);
1820 assert_eq!(total_order(once.clone()), once);
1821 }
1822}