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 mut sorts: Vec<(String, FilterSortDirection)> = query_filter
480 .sorts
481 .iter()
482 .map(|s| (s.field.clone(), s.direction.clone()))
483 .collect();
484 if cursor_walk || !sorts.is_empty() {
485 if sorts.is_empty() {
486 sorts.push(("id".into(), FilterSortDirection::Asc));
487 } else if sorts.last().map(|(f, _)| f != "id").unwrap_or(true) {
488 sorts.push(("id".into(), FilterSortDirection::Asc));
489 }
490 }
491
492 if !sorts.is_empty() {
495 boundary_casts = self.sort_column_casts(&sorts).await?;
496 }
497
498 if cursor_walk {
499 let opaque = if backwards {
500 query_filter.cursor_before.clone().unwrap()
501 } else {
502 query_filter.cursor_after.clone().unwrap()
503 };
504 let payload = crate::filter::cursor::decode_cursor(&opaque, &sorts).map_err(|e| {
505 let parameter = if backwards { "before" } else { "after" };
508 let why = match e {
509 crate::filter::CursorError::SortMismatch { .. } => {
510 "it was issued for a different sort order"
511 }
512 crate::filter::CursorError::Version(_) => "it was issued by an older version",
513 _ => "it is not a position this list issued",
514 };
515 anyhow::Error::from(crate::filter::QueryParamError::new(
516 parameter,
517 crate::filter::QueryParamReason::InvalidCursor,
518 format!("`{parameter}` cannot continue this list: {why}"),
519 ))
520 })?;
521 let mut idx = filter_params.len() + 1;
522 let (keyset_sql, keyset_params) = crate::filter::cursor::build_keyset_predicate(
523 &payload,
524 &mut idx,
525 &boundary_casts,
526 backwards,
527 );
528 where_clause = and_keyset_predicate(&where_clause, &keyset_sql);
529 filter_params.extend(keyset_params);
530 let parts: Vec<String> = sorts
531 .iter()
532 .map(|(f, d)| {
533 let dir = if (*d == FilterSortDirection::Desc) != backwards {
534 "DESC"
535 } else {
536 "ASC"
537 };
538 format!("{} {}", f, dir)
539 })
540 .collect();
541 order_clause = format!(" ORDER BY {}", parts.join(", "));
542 } else {
543 if sorts.is_empty() {
546 order_clause = query_filter.build_order_by_clause();
547 } else {
548 let parts: Vec<String> = sorts
549 .iter()
550 .map(|(f, d)| {
551 let dir =
552 if *d == FilterSortDirection::Desc { "DESC" } else { "ASC" };
553 format!("{} {}", f, dir)
554 })
555 .collect();
556 order_clause = format!(" ORDER BY {}", parts.join(", "));
557 }
558 }
559 let boundary_sorts: Vec<(String, FilterSortDirection)> = sorts;
561
562 let (total, count_mode) = if query_filter.estimate_total {
568 let estimate = self.estimate_filtered_rows(&where_clause, &filter_params).await?;
569 if estimate <= EXACT_COUNT_BELOW {
570 (self.count_filtered_rows(&where_clause, &filter_params).await?, "exact")
571 } else {
572 (estimate, "estimate")
573 }
574 } else if cursor_walk {
575 (0u64, "none")
576 } else {
577 (self.count_filtered_rows(&where_clause, &filter_params).await?, "exact")
578 };
579
580 let fetch = limit + 1;
583 let data_query = if cursor_walk {
584 format!(
585 "SELECT * FROM {}{}{} LIMIT {}",
586 self.table_name, where_clause, order_clause, fetch
587 )
588 } else {
589 format!(
590 "SELECT * FROM {}{}{} LIMIT {} OFFSET {}",
591 self.table_name,
592 where_clause,
593 order_clause,
594 fetch,
595 pagination.offset()
596 )
597 };
598
599 let mut pagination_info = PaginationInfo::new(pagination.page, pagination.per_page, total);
600 pagination_info.count_mode = count_mode.to_string();
601
602 let mut rows_query = sqlx::query(&data_query);
607 for param in &filter_params {
608 rows_query = rows_query.bind(param);
609 }
610 let rows: Vec<PgRow> =
611 crate::company_scope::fetch_all_rows_scoped(&self.pool, rows_query).await?;
612 let has_more = rows.len() as i64 > limit;
613 let mut page: Vec<PgRow> = rows.into_iter().take(limit as usize).collect();
614 if backwards {
615 page.reverse();
616 }
617 let data: anyhow::Result<Vec<T>> = page
618 .iter()
619 .map(|row| T::from_row(row).map_err(|e| anyhow::anyhow!("decode row: {e}")))
620 .collect();
621 let data = data?;
622
623 let deterministic = !boundary_sorts.is_empty();
627 let next_cursor = if (has_more || backwards) && deterministic {
628 page.last().and_then(|r| {
629 let casts = if boundary_casts.is_empty() {
630 &boundary_casts
633 } else {
634 &boundary_casts
635 };
636 self.row_cursor(r, &boundary_sorts, casts)
637 })
638 } else {
639 None
640 };
641 let prev_cursor = if !page.is_empty() && deterministic {
642 page.first().and_then(|r| self.row_cursor(r, &boundary_sorts, &boundary_casts))
643 } else {
644 None
645 };
646
647 pagination_info.has_more = Some(has_more);
648 pagination_info.next_cursor = next_cursor;
649 pagination_info.prev_cursor = prev_cursor;
650
651 Ok(PaginatedResult {
652 data,
653 pagination: pagination_info,
654 })
655 }
656
657 async fn count_filtered_rows(
662 &self,
663 where_clause: &str,
664 filter_params: &[String],
665 ) -> anyhow::Result<u64> {
666 let count_query = format!("SELECT COUNT(*) FROM {}{}", self.table_name, where_clause);
667 let mut count_query_builder = sqlx::query_scalar::<_, i64>(&count_query);
668 for param in filter_params {
669 count_query_builder = count_query_builder.bind(param);
670 }
671 Ok(crate::company_scope::fetch_one_scalar_scoped(&self.pool, count_query_builder).await?
672 as u64)
673 }
674
675 async fn estimate_filtered_rows(
676 &self,
677 where_clause: &str,
678 filter_params: &[String],
679 ) -> anyhow::Result<u64> {
680 let explain = format!("EXPLAIN (FORMAT JSON) SELECT 1 FROM {}{}", self.table_name, where_clause);
681 let mut builder = sqlx::query_scalar::<_, serde_json::Value>(&explain);
682 for param in filter_params {
683 builder = builder.bind(param);
684 }
685 let plan: serde_json::Value =
686 crate::company_scope::fetch_one_scalar_scoped(&self.pool, builder).await?;
687 let rows = plan
688 .as_array()
689 .and_then(|a| a.first())
690 .and_then(|top| top.get("Plan"))
691 .and_then(|p| p.get("Plan Rows"))
692 .and_then(|r| r.as_i64())
693 .unwrap_or(0);
694 Ok(rows.max(0) as u64)
695 }
696
697 async fn sort_column_casts(
702 &self,
703 sorts: &[(String, FilterSortDirection)],
704 ) -> anyhow::Result<Vec<Option<String>>> {
705 let (schema, table) = match self.table_name.rsplit_once('.') {
706 Some((s, t)) => (s.to_string(), t.to_string()),
707 None => ("public".to_string(), self.table_name.clone()),
708 };
709 let mut casts: Vec<Option<String>> = Vec::with_capacity(sorts.len());
710 for (field, _) in sorts {
711 let row: Option<(String, String, String)> = sqlx::query_as(
712 "SELECT data_type, coalesce(udt_name, ''), coalesce(udt_schema, '') \
713 FROM information_schema.columns \
714 WHERE table_schema = $1 AND table_name = $2 AND column_name = $3",
715 )
716 .bind(&schema)
717 .bind(&table)
718 .bind(field)
719 .fetch_optional(&self.pool)
720 .await?;
721 let cast = row
722 .map(|(data_type, udt, udt_schema)| cast_suffix(&data_type, &udt, &udt_schema))
723 .flatten();
724 casts.push(cast);
725 }
726 Ok(casts)
727 }
728
729 fn row_cursor(
733 &self,
734 row: &PgRow,
735 sorts: &[(String, FilterSortDirection)],
736 casts: &[Option<String>],
737 ) -> Option<String> {
738 let id: uuid::Uuid = row.try_get("id").ok()?;
739 let mut values: Vec<serde_json::Value> = Vec::with_capacity(sorts.len());
740 for (i, (field, _)) in sorts.iter().enumerate() {
741 let field = field.as_str();
742 let mut data_type = casts.get(i).and_then(|c| c.as_deref()).unwrap_or("");
743 if field == "id" && data_type.is_empty() {
747 data_type = "uuid";
748 }
749 let text: Option<String> = match data_type {
750 "numeric" => row
751 .try_get::<Option<sqlx::types::Decimal>, _>(field)
752 .ok()?
753 .map(|d| d.to_string()),
754 "uuid" => row
755 .try_get::<Option<uuid::Uuid>, _>(field)
756 .ok()?
757 .map(|u| u.to_string()),
758 "timestamptz" => row
759 .try_get::<Option<chrono::DateTime<chrono::Utc>>, _>(field)
760 .ok()?
761 .map(|t| t.to_rfc3339()),
762 "integer" | "smallint" => row
763 .try_get::<Option<i32>, _>(field)
764 .ok()?
765 .map(|n| n.to_string()),
766 "bigint" => row
767 .try_get::<Option<i64>, _>(field)
768 .ok()?
769 .map(|n| n.to_string()),
770 "boolean" => row
771 .try_get::<Option<bool>, _>(field)
772 .ok()?
773 .map(|b| b.to_string()),
774 "date" => row
775 .try_get::<Option<chrono::NaiveDate>, _>(field)
776 .ok()?
777 .map(|d| d.to_string()),
778 _ => row
779 .try_get::<Option<String>, _>(field)
780 .ok()?
781 .filter(|s| !s.is_empty() || data_type.is_empty()),
782 };
783 values.push(serde_json::Value::String(text?));
784 }
785 crate::filter::cursor::encode_cursor(sorts, &values, &id.to_string()).ok()
786 }
787}
788
789pub(crate) enum UpdateStatement {
793 Unchanged(String),
795 Update { query: String, json: String },
797}
798
799pub(crate) fn update_statement<T: Serialize>(
801 table: &str,
802 entity: &T,
803 only: Option<&[String]>,
804 guard: &str,
805) -> anyhow::Result<UpdateStatement> {
806 let json_obj = match serde_json::to_value(entity)? {
807 Value::Object(obj) => obj,
808 _ => return Err(anyhow::anyhow!("Entity must serialize to a JSON object")),
809 };
810
811 let update_columns: Vec<&String> = json_obj
812 .keys()
813 .filter(|k| *k != "id")
814 .filter(|k| only.is_none_or(|cols| cols.iter().any(|c| c == *k)))
815 .collect();
816
817 if update_columns.is_empty() {
818 return Ok(UpdateStatement::Unchanged(format!(
819 "SELECT t.* FROM {table} AS t WHERE t.id = $1::uuid{guard}"
820 )));
821 }
822
823 let column_names = update_columns
824 .iter()
825 .map(|k| quote_ident(k))
826 .collect::<Vec<_>>()
827 .join(", ");
828 let json = serde_json::to_string(&json_obj)?;
829
830 let query = format!(
833 r#"
834 WITH new_row AS (
835 SELECT (jsonb_populate_record(NULL::{table}, $1::jsonb)).*
836 )
837 UPDATE {table} AS t
838 SET ({columns}) = (SELECT {columns} FROM new_row)
839 WHERE t.id = $2::uuid{guard}
840 RETURNING t.*
841 "#,
842 table = table,
843 columns = column_names,
844 guard = guard,
845 );
846 Ok(UpdateStatement::Update { query, json })
847}
848
849pub(crate) fn insert_statement<T: Serialize>(
852 table: &str,
853 entity: &T,
854) -> anyhow::Result<(String, Option<String>)> {
855 let json_obj = match serde_json::to_value(entity)? {
856 Value::Object(obj) => obj,
857 _ => return Err(anyhow::anyhow!("Entity must serialize to a JSON object")),
858 };
859
860 let insert_columns: Vec<String> = json_obj.keys().map(|k| quote_ident(k)).collect();
874
875 if insert_columns.is_empty() {
876 return Ok((format!("INSERT INTO {table} DEFAULT VALUES RETURNING *"), None));
879 }
880 let columns = insert_columns.join(", ");
881 let query = format!(
882 r#"
883 INSERT INTO {table} ({columns})
884 SELECT {columns} FROM jsonb_populate_record(NULL::{table}, $1::jsonb)
885 RETURNING *
886 "#,
887 );
888 Ok((query, Some(serde_json::to_string(&json_obj)?)))
889}
890
891pub fn changed_columns<T: Serialize>(before: &T, after: &T) -> anyhow::Result<Vec<String>> {
896 let (Value::Object(before), Value::Object(after)) =
897 (serde_json::to_value(before)?, serde_json::to_value(after)?)
898 else {
899 return Err(anyhow::anyhow!("Entity must serialize to a JSON object"));
900 };
901 Ok(after
902 .into_iter()
903 .filter(|(k, v)| k != "id" && before.get(k) != Some(v))
904 .map(|(k, _)| k)
905 .collect())
906}
907
908fn and_keyset_predicate(where_clause: &str, keyset_sql: &str) -> String {
912 match where_clause.trim_start().strip_prefix("WHERE ") {
913 None => format!(" WHERE {}", keyset_sql),
914 Some(existing) => format!(" WHERE ({}) AND ({})", existing, keyset_sql),
915 }
916}
917
918fn cast_suffix(data_type: &str, udt_name: &str, udt_schema: &str) -> Option<String> {
919 match data_type {
920 "uuid" => Some("uuid".into()),
921 "numeric" => Some("numeric".into()),
922 "integer" => Some("integer".into()),
923 "smallint" => Some("smallint".into()),
924 "bigint" => Some("bigint".into()),
925 "boolean" => Some("boolean".into()),
926 "date" => Some("date".into()),
927 "timestamp with time zone" => Some("timestamptz".into()),
928 "timestamp without time zone" => Some("timestamp".into()),
929 "USER-DEFINED" if !udt_name.is_empty() => Some(match udt_schema {
933 "" | "public" | "pg_catalog" => udt_name.to_string(),
934 schema => format!("{schema}.{udt_name}"),
935 }),
936 _ => None,
937 }
938}
939
940fn filter_cast_for(type_name: &str, typtype: &str) -> Option<String> {
945 match typtype {
946 "e" => Some(type_name.to_string()),
949 "b" => match type_name {
950 "uuid" | "boolean" | "smallint" | "integer" | "bigint" | "numeric" | "real"
951 | "double precision" | "date" | "interval" | "inet" | "cidr" | "macaddr" => {
952 Some(type_name.to_string())
953 }
954 "time without time zone" => Some("time".into()),
955 "time with time zone" => Some("timetz".into()),
956 "timestamp with time zone" => Some("timestamptz".into()),
957 "timestamp without time zone" => Some("timestamp".into()),
958 _ => None,
959 },
960 _ => None,
961 }
962}
963
964async fn catalog_filter_casts(
971 pool: &PgPool,
972 qualified_table: &str,
973) -> anyhow::Result<HashMap<String, String>> {
974 let q = sqlx::query_as::<Postgres, (String, String, String)>(
975 "SELECT a.attname::text, format_type(a.atttypid, NULL), t.typtype::text
976 FROM pg_catalog.pg_attribute a
977 JOIN pg_catalog.pg_type t ON t.oid = a.atttypid
978 WHERE a.attrelid = to_regclass($1) AND a.attnum > 0 AND NOT a.attisdropped",
979 )
980 .bind(qualified_table.to_string());
981 let rows = crate::company_scope::fetch_all_scoped(pool, q).await?;
982 Ok(rows
983 .into_iter()
984 .filter_map(|(column, type_name, typtype)| {
985 filter_cast_for(&type_name, &typtype).map(|cast| (column, cast))
986 })
987 .collect())
988}
989
990fn merge_filter_casts(
995 hints: &HashMap<String, String>,
996 mut catalog: HashMap<String, String>,
997) -> HashMap<String, String> {
998 for (column, cast) in hints {
999 let qualified_same_type = catalog
1000 .get(column)
1001 .and_then(|c| c.rsplit_once('.'))
1002 .is_some_and(|(_, name)| name.trim_matches('"') == cast.as_str());
1003 if !qualified_same_type {
1004 catalog.insert(column.clone(), cast.clone());
1005 }
1006 }
1007 catalog
1008}
1009
1010pub(crate) fn explain_unknown_column(error: sqlx::Error, table: &str) -> anyhow::Error {
1018 let text = error.to_string();
1019 if text.contains("does not exist") && text.contains("column") {
1020 return anyhow::Error::new(error).context(format!(
1021 "insert into {table} named a column that does not exist: the entity serializes a field \
1022 with no matching column. Every serialized field must be a column of the table (rename \
1023 it, map it with #[serde(rename)], or skip it with #[serde(skip)])"
1024 ));
1025 }
1026 anyhow::Error::new(error)
1027}
1028
1029fn quote_ident(name: &str) -> String {
1036 format!("\"{}\"", name.replace('"', "\"\""))
1037}
1038
1039#[async_trait]
1040impl<T> DatabaseOperations<T> for PostgresRepository<T>
1041where
1042 T: for<'a> FromRow<'a, PgRow> + Send + Sync + Unpin + Serialize,
1043{
1044 async fn create(&self, entity: &T) -> anyhow::Result<T> {
1045 let (query, json) = insert_statement(&self.table_name, entity)?;
1046 let statement = match json {
1048 Some(json) => sqlx::query_as::<_, T>(&query).bind(json),
1049 None => sqlx::query_as::<_, T>(&query),
1050 };
1051 let result = crate::company_scope::fetch_one_scoped(&self.pool, statement)
1052 .await
1053 .map_err(|e| explain_unknown_column(e, &self.table_name))?;
1054
1055 Ok(result)
1056 }
1057
1058 async fn find_by_id(&self, id: &str) -> anyhow::Result<Option<T>> {
1059 let query = format!("SELECT * FROM {} WHERE id = $1::uuid", self.table_name);
1061 let result = crate::company_scope::fetch_optional_scoped(
1062 &self.pool,
1063 sqlx::query_as::<Postgres, T>(&query).bind(id),
1064 )
1065 .await?;
1066 Ok(result)
1067 }
1068
1069 async fn find_all(&self) -> anyhow::Result<Vec<T>> {
1070 let query = format!("SELECT * FROM {}", self.table_name);
1071 let results = crate::company_scope::fetch_all_scoped(
1072 &self.pool,
1073 sqlx::query_as::<Postgres, T>(&query),
1074 )
1075 .await?;
1076 Ok(results)
1077 }
1078
1079 async fn update(&self, id: &str, entity: &T) -> anyhow::Result<Option<T>> {
1080 self.update_guarded(id, entity, None, "").await
1081 }
1082
1083 async fn delete(&self, id: &str) -> anyhow::Result<bool> {
1084 let query = format!("DELETE FROM {} WHERE id = $1::uuid", self.table_name);
1085 let result = crate::company_scope::execute_scoped(
1086 &self.pool,
1087 sqlx::query(&query).bind(id),
1088 )
1089 .await?;
1090 Ok(result.rows_affected() > 0)
1091 }
1092
1093 async fn count(&self) -> anyhow::Result<u64> {
1094 let query = format!("SELECT COUNT(*) FROM {}", self.table_name);
1095 let count = crate::company_scope::fetch_one_scalar_scoped(
1096 &self.pool,
1097 sqlx::query_scalar::<_, i64>(&query),
1098 )
1099 .await? as u64;
1100 Ok(count)
1101 }
1102
1103 async fn exists(&self, id: &str) -> anyhow::Result<bool> {
1104 let query = format!("SELECT 1 FROM {} WHERE id = $1::uuid LIMIT 1", self.table_name);
1105 let result = crate::company_scope::fetch_optional_scalar_scoped(
1106 &self.pool,
1107 sqlx::query_scalar::<_, i32>(&query).bind(id),
1108 )
1109 .await?;
1110 Ok(result.is_some())
1111 }
1112
1113 async fn execute_query(&self, query: &str) -> anyhow::Result<u64> {
1114 let result = crate::company_scope::execute_scoped(
1115 &self.pool,
1116 sqlx::query(query),
1117 )
1118 .await?;
1119 Ok(result.rows_affected())
1120 }
1121}
1122
1123#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1127pub enum AggregateFn {
1128 Sum,
1129 Avg,
1130 Min,
1131 Max,
1132}
1133
1134impl AggregateFn {
1135 fn sql(self) -> &'static str {
1136 match self {
1137 AggregateFn::Sum => "SUM",
1138 AggregateFn::Avg => "AVG",
1139 AggregateFn::Min => "MIN",
1140 AggregateFn::Max => "MAX",
1141 }
1142 }
1143
1144 fn label(self) -> &'static str {
1145 match self {
1146 AggregateFn::Sum => "sum",
1147 AggregateFn::Avg => "avg",
1148 AggregateFn::Min => "min",
1149 AggregateFn::Max => "max",
1150 }
1151 }
1152
1153 fn requires_numeric(self) -> bool {
1156 matches!(self, AggregateFn::Sum | AggregateFn::Avg)
1157 }
1158}
1159
1160#[derive(Debug, Clone, Default)]
1162pub struct AggregateSpec {
1163 pub group_by: Option<String>,
1165 pub reductions: Vec<(AggregateFn, String)>,
1167 pub group_limit: usize,
1169 pub label_field: Option<String>,
1173 pub label_relation: Option<(String, String)>,
1177}
1178
1179pub const DEFAULT_GROUP_LIMIT: usize = 200;
1187
1188#[derive(Debug, Clone, Serialize, Deserialize)]
1191pub struct AggregateGroup {
1192 pub key: Option<String>,
1193 pub label: Option<String>,
1196 pub count: u64,
1197 pub values: HashMap<String, Option<String>>,
1205}
1206
1207#[derive(Debug, Clone, Serialize, Deserialize)]
1209pub struct AggregateResult {
1210 pub groups: Vec<AggregateGroup>,
1211 pub total: AggregateGroup,
1212 pub truncated: bool,
1214}
1215
1216#[derive(Debug, Clone)]
1219pub struct AggregateFieldError(pub String);
1220
1221impl std::fmt::Display for AggregateFieldError {
1222 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1223 f.write_str(&self.0)
1224 }
1225}
1226
1227impl std::error::Error for AggregateFieldError {}
1228
1229fn is_numeric_pg_type(pg_type: &str) -> bool {
1231 let t = pg_type.trim().to_ascii_lowercase();
1232 let t = t.split('(').next().unwrap_or(&t).trim();
1233 matches!(
1234 t,
1235 "numeric" | "decimal" | "money"
1236 | "smallint" | "int2" | "integer" | "int" | "int4" | "bigint" | "int8"
1237 | "real" | "float4" | "double precision" | "float8"
1238 | "smallserial" | "serial" | "bigserial"
1239 )
1240}
1241
1242async fn catalog_columns(
1251 pool: &PgPool,
1252 qualified_table: &str,
1253) -> anyhow::Result<HashMap<String, String>> {
1254 let (schema, table) = match qualified_table.split_once('.') {
1255 Some((s, t)) => (s.to_string(), t.to_string()),
1256 None => ("public".to_string(), qualified_table.to_string()),
1257 };
1258 let q = sqlx::query_as::<Postgres, (String, String)>(
1259 "SELECT column_name, data_type FROM information_schema.columns
1260 WHERE table_schema = $1 AND table_name = $2",
1261 )
1262 .bind(schema)
1263 .bind(table);
1264 let rows = crate::company_scope::fetch_all_scoped(pool, q).await?;
1265 Ok(rows.into_iter().collect())
1266}
1267
1268fn resolve_column<'a>(
1277 name: &str,
1278 column_types: &'a HashMap<String, String>,
1279) -> Result<(&'a str, &'a str), AggregateFieldError> {
1280 column_types
1281 .get_key_value(name)
1282 .map(|(k, v)| (k.as_str(), v.as_str()))
1283 .ok_or_else(|| {
1284 AggregateFieldError(format!("unknown field `{name}` — not a column of this entity"))
1285 })
1286}
1287
1288impl<T: for<'a> FromRow<'a, PgRow> + Send + Unpin> PostgresRepository<T> {
1289 pub async fn aggregate_with_filters(
1297 &self,
1298 spec: &AggregateSpec,
1299 filters: &HashMap<String, String>,
1300 column_types: &HashMap<String, String>,
1301 search_fields: &[&str],
1302 ) -> anyhow::Result<AggregateResult> {
1303 self.aggregate_with_filters_and_base(spec, None, filters, column_types, search_fields)
1304 .await
1305 }
1306
1307 pub(crate) async fn aggregate_with_filters_and_base(
1311 &self,
1312 spec: &AggregateSpec,
1313 base_condition: Option<&str>,
1314 filters: &HashMap<String, String>,
1315 column_types: &HashMap<String, String>,
1316 search_fields: &[&str],
1317 ) -> anyhow::Result<AggregateResult> {
1318 match self
1319 .aggregate_unattributed(spec, base_condition, filters, column_types, search_fields)
1320 .await
1321 {
1322 Ok(result) => Ok(result),
1323 Err(e) => Err(self.name_refused_parameter(e, filters).await),
1324 }
1325 }
1326
1327 async fn name_refused_parameter(
1333 &self,
1334 err: anyhow::Error,
1335 filters: &HashMap<String, String>,
1336 ) -> anyhow::Error {
1337 let err = crate::filter::attribute_query_error(err, filters);
1338 if crate::filter::undefined_operator(&err).is_none() {
1339 return err;
1340 }
1341 let (schema, table) = match self.table_name.split_once('.') {
1342 Some((s, t)) => (s.to_string(), t.to_string()),
1343 None => ("public".to_string(), self.table_name.clone()),
1344 };
1345 let types: Vec<(String, String)> = sqlx::query_as(
1346 "SELECT column_name, data_type FROM information_schema.columns \
1347 WHERE table_schema = $1 AND table_name = $2",
1348 )
1349 .bind(schema)
1350 .bind(table)
1351 .fetch_all(&self.pool)
1352 .await
1353 .unwrap_or_default();
1354 let types: HashMap<String, String> = types.into_iter().collect();
1355 crate::filter::attribute_operator_error(err, filters, &types)
1356 }
1357
1358 async fn aggregate_unattributed(
1361 &self,
1362 spec: &AggregateSpec,
1363 base_condition: Option<&str>,
1364 filters: &HashMap<String, String>,
1365 column_types: &HashMap<String, String>,
1366 search_fields: &[&str],
1367 ) -> anyhow::Result<AggregateResult> {
1368 let mut query_filter = self.parse_typed_filters(filters, column_types, None).await?;
1369 if let Some(cond) = base_condition {
1370 query_filter.add_base_condition(cond);
1371 }
1372 if !search_fields.is_empty() {
1373 query_filter.search_fields = search_fields.iter().map(|s| s.to_string()).collect();
1374 }
1375 query_filter.limit = None;
1378 query_filter.offset = None;
1379 let (where_clause, filter_params) = query_filter.build_where_clause();
1380
1381 let columns = catalog_columns(&self.pool, &self.table_name).await?;
1383 let mut selects: Vec<String> = Vec::new();
1384 let mut value_keys: Vec<String> = Vec::new();
1385 for (func, field) in &spec.reductions {
1386 let (column, pg_type) = resolve_column(field, &columns)
1387 .map_err(|_| crate::filter::QueryParamError::unknown_column(func.label(), field))?;
1388 if func.requires_numeric() && !is_numeric_pg_type(pg_type) {
1389 return Err(crate::filter::QueryParamError::new(
1390 func.label(),
1391 crate::filter::QueryParamReason::NotNumeric,
1392 format!(
1393 "`{label}`: `{field}` is not a numeric field, and {label} needs one",
1394 label = func.label()
1395 ),
1396 )
1397 .into());
1398 }
1399 let key = format!("{}:{}", func.label(), column);
1400 selects.push(format!("{}({})::text AS \"{}\"", func.sql(), column, key));
1403 value_keys.push(key);
1404 }
1405
1406 let group_limit = if spec.group_limit == 0 { DEFAULT_GROUP_LIMIT } else { spec.group_limit };
1407 let reductions = if selects.is_empty() { String::new() } else { format!(", {}", selects.join(", ")) };
1408
1409 let mut label_select = String::new();
1415 let mut label_join = String::new();
1416 if let (Some(field), Some(label_field), Some((rel_table, base_fk))) = (
1417 &spec.group_by,
1418 &spec.label_field,
1419 &spec.label_relation,
1420 ) {
1421 let _ = field;
1422 let qualified = qualify_relation_table(&self.table_name, rel_table);
1423 let rel_columns = catalog_columns(&self.pool, &qualified).await?;
1424 let (label_col, _) = resolve_column(label_field, &rel_columns).map_err(|_| {
1425 crate::filter::QueryParamError::new(
1426 "group_label",
1427 crate::filter::QueryParamReason::UnknownColumn,
1428 format!("`group_label`: `{label_field}` is not a field of the records the groups point at"),
1429 )
1430 })?;
1431 label_select = format!(", (label_rel.{label_col})::text AS __group_label");
1432 label_join = format!(
1433 " LEFT JOIN {qualified} AS label_rel ON label_rel.id IS NOT DISTINCT FROM {base_fk}"
1434 );
1435 }
1436
1437 let sql = match &spec.group_by {
1438 Some(field) => {
1439 let (column, _) = resolve_column(field, &columns).map_err(|_| {
1440 crate::filter::QueryParamError::unknown_column("group_by", field)
1441 })?;
1442 format!(
1443 "SELECT GROUPING({column}) AS __is_total, ({column})::text AS __group_key{label_select}, \
1444 COUNT(*) AS __count{reductions} \
1445 FROM {table}{label_join}{where_clause} \
1446 GROUP BY GROUPING SETS (({column}), ()) \
1447 ORDER BY __is_total DESC, __count DESC \
1448 LIMIT {limit}",
1449 column = column,
1450 label_select = label_select,
1451 label_join = label_join,
1452 reductions = reductions,
1453 table = self.table_name,
1454 where_clause = where_clause,
1455 limit = group_limit + 2,
1458 )
1459 }
1460 None => format!(
1461 "SELECT 1 AS __is_total, NULL::text AS __group_key, COUNT(*) AS __count{reductions} \
1462 FROM {table}{where_clause}",
1463 reductions = reductions,
1464 table = self.table_name,
1465 where_clause = where_clause,
1466 ),
1467 };
1468
1469 let mut builder = sqlx::query(&sql);
1470 for param in &filter_params {
1471 builder = builder.bind(param);
1472 }
1473 let rows = crate::company_scope::fetch_all_rows_scoped(&self.pool, builder).await?;
1474
1475 let read_group = |row: &PgRow| -> AggregateGroup {
1476 use sqlx::Row as _;
1477 let mut values = HashMap::with_capacity(value_keys.len());
1478 for key in &value_keys {
1479 values.insert(key.clone(), row.try_get::<Option<String>, _>(key.as_str()).ok().flatten());
1480 }
1481 AggregateGroup {
1482 key: row.try_get::<Option<String>, _>("__group_key").ok().flatten(),
1483 label: row.try_get::<Option<String>, _>("__group_label").ok().flatten(),
1484 count: row.try_get::<i64, _>("__count").unwrap_or(0).max(0) as u64,
1485 values,
1486 }
1487 };
1488
1489 use sqlx::Row as _;
1490 let mut total: Option<AggregateGroup> = None;
1491 let mut groups: Vec<AggregateGroup> = Vec::new();
1492 for row in &rows {
1493 let is_total = row.try_get::<i32, _>("__is_total").unwrap_or(0) == 1;
1494 if is_total {
1495 total = Some(read_group(row));
1497 } else {
1498 groups.push(read_group(row));
1499 }
1500 }
1501
1502 let truncated = groups.len() > group_limit;
1503 groups.truncate(group_limit);
1504
1505 let total = total.unwrap_or_else(|| AggregateGroup {
1508 key: None,
1509 label: None,
1510 count: 0,
1511 values: value_keys.iter().map(|k| (k.clone(), None)).collect(),
1512 });
1513
1514 Ok(AggregateResult { groups, total, truncated })
1515 }
1516}
1517
1518#[cfg(test)]
1519mod aggregate_field_tests {
1520 use super::*;
1521
1522 fn columns() -> HashMap<String, String> {
1523 [
1524 ("status", "text"),
1525 ("total", "numeric"),
1526 ("qty", "integer"),
1527 ("notes", "text"),
1528 ]
1529 .iter()
1530 .map(|(k, v)| (k.to_string(), v.to_string()))
1531 .collect()
1532 }
1533
1534 #[test]
1535 fn resolves_only_declared_columns() {
1536 let cols = columns();
1537 assert_eq!(resolve_column("total", &cols).unwrap().0, "total");
1538 assert!(resolve_column("password_hash", &cols).is_err());
1539 }
1540
1541 #[test]
1544 fn rejects_injection_attempts_rather_than_escaping_them() {
1545 let cols = columns();
1546 for probe in [
1547 "total) FROM selling.sales_orders; DROP TABLE users --",
1548 "status\"",
1549 "1=1",
1550 "total, (SELECT password FROM users)",
1551 "",
1552 ] {
1553 assert!(
1554 resolve_column(probe, &cols).is_err(),
1555 "`{probe}` must be refused, never escaped into the query"
1556 );
1557 }
1558 }
1559
1560 #[test]
1563 fn returns_the_declared_key_not_the_callers_string() {
1564 let cols = columns();
1565 let (name, _) = resolve_column("total", &cols).unwrap();
1566 assert!(std::ptr::eq(name, cols.get_key_value("total").unwrap().0.as_str()));
1567 }
1568
1569 #[test]
1570 fn sum_and_avg_require_a_numeric_type() {
1571 assert!(AggregateFn::Sum.requires_numeric());
1572 assert!(AggregateFn::Avg.requires_numeric());
1573 assert!(!AggregateFn::Min.requires_numeric());
1575 assert!(!AggregateFn::Max.requires_numeric());
1576 }
1577
1578 #[test]
1579 fn recognises_the_numeric_postgres_types() {
1580 for t in ["numeric", "NUMERIC(14,2)", "integer", "bigint", "double precision", "money"] {
1581 assert!(is_numeric_pg_type(t), "{t} should count as numeric");
1582 }
1583 for t in ["text", "uuid", "timestamptz", "boolean", "jsonb", "USER-DEFINED"] {
1584 assert!(!is_numeric_pg_type(t), "{t} must not accept a SUM");
1585 }
1586 }
1587}
1588
1589#[cfg(test)]
1590mod filter_cast_tests {
1591 use super::*;
1592
1593 #[test]
1594 fn typed_base_columns_get_their_own_cast() {
1595 for (t, want) in [
1596 ("boolean", "boolean"),
1597 ("integer", "integer"),
1598 ("bigint", "bigint"),
1599 ("smallint", "smallint"),
1600 ("numeric", "numeric"),
1601 ("double precision", "double precision"),
1602 ("uuid", "uuid"),
1603 ("date", "date"),
1604 ("time without time zone", "time"),
1605 ("timestamp with time zone", "timestamptz"),
1606 ("timestamp without time zone", "timestamp"),
1607 ] {
1608 assert_eq!(filter_cast_for(t, "b").as_deref(), Some(want), "{t}");
1609 }
1610 }
1611
1612 #[test]
1613 fn text_like_and_composite_columns_keep_the_text_bind() {
1614 for t in ["text", "character varying", "character", "jsonb", "json", "bytea", "text[]", "uuid[]"] {
1615 assert_eq!(filter_cast_for(t, "b"), None, "{t}");
1616 }
1617 assert_eq!(filter_cast_for("approvals.money_amount", "d"), None);
1619 assert_eq!(filter_cast_for("approvals.address", "c"), None);
1620 }
1621
1622 #[test]
1623 fn an_enum_casts_to_its_catalog_name() {
1624 assert_eq!(filter_cast_for("approval_status", "e").as_deref(), Some("approval_status"));
1625 assert_eq!(
1626 filter_cast_for("recruitment.stage_kind", "e").as_deref(),
1627 Some("recruitment.stage_kind")
1628 );
1629 }
1630
1631 #[test]
1632 fn a_generated_hint_wins_over_the_catalog_for_its_column() {
1633 let hints: HashMap<String, String> =
1634 [("id", "uuid"), ("status", "approval_status")].iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
1635 let catalog: HashMap<String, String> = [
1636 ("id", "uuid"),
1637 ("status", "approvals.approval_status"),
1638 ("folded", "boolean"),
1639 ("requested_by", "uuid"),
1640 ]
1641 .iter()
1642 .map(|(k, v)| (k.to_string(), v.to_string()))
1643 .collect();
1644 let merged = merge_filter_casts(&hints, catalog);
1645 assert_eq!(merged["status"], "approvals.approval_status");
1648 assert_eq!(merged["id"], "uuid");
1649 assert_eq!(merged["folded"], "boolean");
1650 assert_eq!(merged["requested_by"], "uuid");
1651 assert_eq!(merged.len(), 4);
1652 }
1653
1654 #[test]
1655 fn a_hint_for_another_type_still_decides_its_column() {
1656 let hints: HashMap<String, String> =
1657 [("amount", "numeric")].iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
1658 let catalog: HashMap<String, String> =
1659 [("amount", "real")].iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
1660 assert_eq!(merge_filter_casts(&hints, catalog)["amount"], "numeric");
1661 }
1662
1663 #[test]
1664 fn a_filter_on_a_bare_enum_hint_reads_the_catalog_to_qualify_it() {
1665 let hints: HashMap<String, String> = [("id", "uuid"), ("status", "task_status")]
1666 .iter()
1667 .map(|(k, v)| (k.to_string(), v.to_string()))
1668 .collect();
1669 let needs = |pairs: &[(&str, &str)]| {
1670 let f: HashMap<String, String> =
1671 pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
1672 parse_query_filter(&f, &hints, None).unwrap().needs_catalog_types()
1673 };
1674 assert!(needs(&[("status[in]", "done,skipped")]));
1675 assert!(needs(&[("status[eq]", "done")]));
1676 assert!(!needs(&[("id[in]", "a,b")]));
1678 }
1679
1680 #[test]
1681 fn a_sort_on_an_enum_casts_to_its_schema_qualified_name() {
1682 assert_eq!(
1683 cast_suffix("USER-DEFINED", "task_status", "lifecycle").as_deref(),
1684 Some("lifecycle.task_status")
1685 );
1686 assert_eq!(cast_suffix("USER-DEFINED", "mood", "public").as_deref(), Some("mood"));
1687 assert_eq!(cast_suffix("uuid", "uuid", "pg_catalog").as_deref(), Some("uuid"));
1688 }
1689
1690 #[test]
1691 fn only_a_filter_with_an_uncast_comparison_reads_the_catalog() {
1692 let hints: HashMap<String, String> =
1693 [("id", "uuid")].iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
1694 let parse = |pairs: &[(&str, &str)]| {
1695 let f: HashMap<String, String> =
1696 pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
1697 parse_query_filter(&f, &hints, None).unwrap().has_uncast_value_conditions()
1698 };
1699 assert!(!parse(&[("id[in]", "a,b"), ("name[contain]", "x"), ("limit", "5")]));
1700 assert!(!parse(&[("deleted_by[isnull]", "1")]));
1701 assert!(parse(&[("folded[eq]", "false")]));
1702 assert!(parse(&[("folded", "false")]));
1703 assert!(parse(&[("scheduled_at[between]", "2026-10-01,2026-10-03")]));
1704 assert!(parse(&[("sequence[or]", "3")]));
1705 }
1706}
1707
1708#[cfg(test)]
1709mod keyset_predicate_tests {
1710 use super::and_keyset_predicate;
1711
1712 #[test]
1713 fn a_client_or_cannot_escape_the_keyset_window() {
1714 assert_eq!(
1715 and_keyset_predicate(" WHERE kind = $1 OR status = $2", "id > $3"),
1716 " WHERE (kind = $1 OR status = $2) AND (id > $3)"
1717 );
1718 }
1719
1720 #[test]
1721 fn no_filter_leaves_the_keyset_alone() {
1722 assert_eq!(and_keyset_predicate("", "id > $1"), " WHERE id > $1");
1723 }
1724}
1725
1726#[cfg(test)]
1727mod changed_columns_tests {
1728 use super::changed_columns;
1729
1730 #[derive(serde::Serialize)]
1731 struct Row {
1732 id: &'static str,
1733 notes: &'static str,
1734 status: &'static str,
1735 #[serde(skip_serializing_if = "Option::is_none")]
1736 label: Option<&'static str>,
1737 }
1738
1739 #[test]
1740 fn only_the_fields_that_differ_are_changed() {
1741 let before = Row {
1742 id: "1",
1743 notes: "a",
1744 status: "draft",
1745 label: None,
1746 };
1747 let after = Row {
1748 id: "2",
1749 notes: "b",
1750 status: "draft",
1751 label: Some("x"),
1752 };
1753 assert_eq!(
1754 changed_columns(&before, &after).unwrap(),
1755 vec!["label".to_string(), "notes".to_string()]
1756 );
1757 assert!(changed_columns(&before, &before).unwrap().is_empty());
1758 }
1759}