Skip to main content

backbone_orm/
repository.rs

1//! Repository implementations for PostgreSQL with comprehensive CRUD operations
2
3use 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
15/// Generic entity trait that all repository entities must implement
16pub trait Entity {
17    /// Get the entity's ID
18    fn id(&self) -> Option<&str>;
19
20    /// Get the table name for this entity
21    fn table_name() -> &'static str where Self: Sized;
22
23    /// Check if entity is soft deleted
24    fn is_deleted(&self) -> bool { false }
25
26    /// Get creation timestamp
27    fn created_at(&self) -> Option<NaiveDateTime> { None }
28
29    /// Get update timestamp
30    fn updated_at(&self) -> Option<NaiveDateTime> { None }
31}
32
33/// Pagination parameters
34#[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), // Limit to 1-100 per page
45        }
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/// Sorting parameters
58#[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/// Filter parameters
72#[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/// Paginated result wrapper
90#[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    /// Keyset paging: the position of this page's last row, for the next
103    /// page's `after=`. None when the page is empty or no more rows follow.
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub next_cursor: Option<String>,
106    /// Keyset paging: the position of this page's first row, for the
107    /// previous page's `before=`.
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub prev_cursor: Option<String>,
110    /// Whether another page follows (fetched with limit+1, so it is known
111    /// without a count).
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub has_more: Option<bool>,
114    /// How `total` came to be: "exact" (counted), "estimate" (the planner's
115    /// row estimate — `estimate=1` asked for it), or "none" (a cursor walk
116    /// without an estimate; the exact figure is the separate count call).
117    #[serde(default = "default_count_mode")]
118    pub count_mode: String,
119}
120
121/// Below this many rows (by the planner's estimate) an estimated total is counted exactly
122/// instead: the count is cheap, and a planner estimate is least reliable on small sets.
123pub 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/// Database operations trait - requires Serialize for write operations
146#[async_trait]
147pub trait DatabaseOperations<T: for<'a> FromRow<'a, PgRow> + Send + Unpin> {
148    /// Create a new entity
149    async fn create(&self, entity: &T) -> anyhow::Result<T>;
150
151    /// Find entity by ID
152    async fn find_by_id(&self, id: &str) -> anyhow::Result<Option<T>>;
153
154    /// Find all entities
155    async fn find_all(&self) -> anyhow::Result<Vec<T>>;
156
157    /// Update an existing entity
158    async fn update(&self, id: &str, entity: &T) -> anyhow::Result<Option<T>>;
159
160    /// Delete an entity
161    async fn delete(&self, id: &str) -> anyhow::Result<bool>;
162
163    /// Count all entities
164    async fn count(&self) -> anyhow::Result<u64>;
165
166    /// Check if entity exists
167    async fn exists(&self, id: &str) -> anyhow::Result<bool>;
168
169    /// Execute custom query
170    async fn execute_query(&self, query: &str) -> anyhow::Result<u64>;
171}
172
173/// PostgreSQL repository implementation with JSON-based dynamic queries
174pub 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    /// List entities with pagination and advanced filtering
198    ///
199    /// This method provides comprehensive filtering capabilities similar to Laravel's Filter Query String.
200    ///
201    /// # Supported Filter Operators
202    ///
203    /// - `field[eq]=value` - Equal
204    /// - `field[notEq]=value` - Not equal
205    /// - `field[gt]=value` - Greater than
206    /// - `field[gte]=value` - Greater than or equal
207    /// - `field[lt]=value` - Less than
208    /// - `field[lte]=value` - Less than or equal
209    /// - `field[like]=value` - LIKE (case-sensitive)
210    /// - `field[ilike]=value` - ILIKE (case-insensitive)
211    /// - `field[notlike]=value` - NOT LIKE
212    /// - `field[contain]=value` - Contains (%value%)
213    /// - `field[notcontain]=value` - Does not contain
214    /// - `field[startwith]=value` - Starts with (value%)
215    /// - `field[endwith]=value` - Ends with (%value)
216    /// - `field[in]=val1,val2` - IN array
217    /// - `field[notin]=val1,val2` - NOT IN array
218    /// - `field[between]=val1,val2` - BETWEEN
219    /// - `field[notbetween]=val1,val2` - NOT BETWEEN
220    /// - `field[isnull]` - IS NULL
221    /// - `field[isnotnull]` - IS NOT NULL
222    ///
223    /// # Special Parameters
224    ///
225    /// - `search=value&searchFields=field1,field2` - Search in multiple fields
226    /// - `orderby=field` or `orderby[field]=asc` - Sort results
227    /// - `limit=10` - Limit results
228    /// - `page=1` - Page number
229    ///
230    /// # Column Type Casting
231    ///
232    /// The `column_types` HashMap maps field names to their PostgreSQL types for proper casting.
233    /// For example, `{"status": "user_status"}` will cast the status parameter to `user_status` enum type.
234    ///
235    /// # Example
236    ///
237    /// ```ignore
238    /// let mut filters = HashMap::new();
239    /// filters.insert("username[contain]".to_string(), "john".to_string());
240    /// filters.insert("age[gt]".to_string(), "18".to_string());
241    ///
242    /// let mut column_types = HashMap::new();
243    /// column_types.insert("status".to_string(), "user_status".to_string());
244    ///
245    /// let result = repo.list_with_filters(
246    ///     PaginationParams::new(1, 10),
247    ///     &filters,
248    ///     &column_types,
249    ///     &["username", "email"]  // search fields
250    /// ).await?;
251    /// ```
252    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    /// [`Self::list_with_filters`] under a repository-authored base condition.
267    ///
268    /// `base_condition` is raw SQL ANDed, as its own parenthesised group, ahead of everything
269    /// the request asked for. It must come from code (a soft-delete guard, the company fence),
270    /// never from the request — which is why it is a separate argument and not a filter key:
271    /// the wire parser ignores every `__`-prefixed key.
272    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        // Parse filters from HashMap (no field allow-list by default for backward compatibility)
284        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        // Set up search fields if provided
290        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    /// List entities with pagination, filtering, and field whitelist enforcement
298    ///
299    /// Similar to `list_with_filters` but accepts an optional set of allowed field names.
300    /// When provided, only filter conditions on whitelisted fields are applied;
301    /// conditions on unknown fields are silently dropped.
302    ///
303    /// This prevents clients from filtering on internal or sensitive columns
304    /// (e.g., `password_hash`, `internal_notes`).
305    ///
306    /// # Arguments
307    ///
308    /// * `pagination` - Page and limit parameters
309    /// * `filters` - HTTP query parameters (e.g., `field[operator]=value`)
310    /// * `column_types` - PostgreSQL type mappings for enum casting
311    /// * `search_fields` - Fields to search when `search` parameter is present
312    /// * `allowed_fields` - Optional whitelist of field names; `None` allows all fields
313    ///
314    /// # Example
315    ///
316    /// ```ignore
317    /// let allowed: HashSet<String> = ["username", "email", "status"]
318    ///     .iter().map(|s| s.to_string()).collect();
319    ///
320    /// let result = repo.list_with_filters_whitelisted(
321    ///     PaginationParams::new(1, 10),
322    ///     &filters,
323    ///     &column_types,
324    ///     &["username", "email"],
325    ///     Some(&allowed),
326    /// ).await?;
327    /// ```
328    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        // Parse filters with optional field whitelist
340        let mut query_filter =
341            self.parse_typed_filters(filters, column_types, allowed_fields).await?;
342
343        // Set up search fields if provided
344        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    /// Update one row by id, setting only `only` columns when given (every serialized column but
352    /// `id` otherwise), and only while `guard` holds.
353    ///
354    /// `guard` is repository-authored SQL ANDed into the `WHERE` after the id match (`""` for
355    /// none), written against the table alias `t` — e.g. `" AND t.metadata->>'deleted_at' IS
356    /// NULL"`. Putting the not-deleted predicate in the UPDATE itself is what keeps a write that
357    /// races a soft delete from reviving the row: a separate existence check before the UPDATE
358    /// leaves a window in which the delete lands. `Ok(None)` when no row matched.
359    ///
360    /// With `only` empty nothing is written, and the current row (under the same guard) is
361    /// returned unchanged.
362    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        // jsonb_populate_record in a CTE gives every value its column's real type (enums,
403        // booleans, numerics) without per-column casts.
404        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    /// Parse the wire filters with a cast for every typed column they compare.
429    ///
430    /// Filter values arrive as text and are bound as text; PostgreSQL has no implicit comparison
431    /// between text and a boolean, number, uuid, date, time or timestamp column, so each such
432    /// comparison needs a cast on its placeholder. The entity's generated `column_types()` hints
433    /// supply it where they exist, and stay the answer for the columns they name. They were never
434    /// a complete list — no booleans or numbers, only uuids named `id`/`*_id`, and temporal
435    /// columns only in modules generated after the generator learned them — so whenever a
436    /// comparison is left without a cast, the table's real column types are read from the
437    /// catalog and fill the gaps. The catalog cannot drift from the table the query runs
438    /// against, which is the same reason the aggregate and sort paths read it.
439    ///
440    /// A filter on text columns only, or with hints for every compared column, costs nothing
441    /// extra beyond the parse; otherwise one catalog lookup on the request's own connection.
442    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    /// The shared list execution: filters, order, paging, and the total.
458    ///
459    /// Three shapes, chosen by the request:
460    ///
461    /// * **page mode** (today's behaviour, unchanged): exact COUNT, then
462    ///   `LIMIT l OFFSET o` in the requested order;
463    /// * **cursor mode** (`after=`/`before=`): the keyset predicate replaces
464    ///   the offset, the order always ends on the `id` tiebreaker, and one
465    ///   extra row is fetched so `has_more` is known without a count — the
466    ///   page costs the same at any depth, which is the point;
467    /// * **estimate** (`estimate=1`, either mode): the exact COUNT (a scan
468    ///   of the whole filtered set) is replaced by the planner's row
469    ///   estimate; the exact figure stays the separate count call.
470    #[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        // Cast suffixes for the sort columns, for the keyset binds.
484        let mut boundary_casts: Vec<Option<String>> = Vec::new();
485
486        // The deterministic order: a cursor walks one, and a page-mode list
487        // that carries a sort gets the same treatment so it can HAND OUT a
488        // cursor to start a keyset walk from. Appending the id tiebreaker
489        // only reorders rows that tied — ties had no order to preserve.
490        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        // Casts whenever there is a deterministic order to key: cursor mode
504        // walks on them, page mode encodes the handed-out cursor from them.
505        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            // Page mode with a sort: the caller's order, made deterministic
540            // by the same id tiebreaker so the cursor it hands out is real.
541            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        // The deterministic order a cursor walks in (cursor mode only).
556        let boundary_sorts: Vec<(String, FilterSortDirection)> = sorts;
557
558        // The total: exact in page mode (today's behaviour), the planner's
559        // estimate when asked, nothing on a cursor walk that did not ask.
560        // Asked for an estimate, a set the planner expects to be small is
561        // counted all the same: the count is cheap there and the estimate is
562        // at its least reliable, so "about 12" never stands in for 10.
563        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        // The page: limit+1 rows so has_more is known without a count. The
577        // extra row is truncated away before returning.
578        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        // One fetch path for both modes: untyped rows, decoded per-entity
599        // through FromRow exactly as query_as would (decimals keep their
600        // scale), with the boundary values read for the cursors off the same
601        // rows — no second query, no JSON round-trip.
602        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        // Cursors from the boundary rows' own values, whenever the order is
620        // deterministic. A NULL in a sort column cannot key a position, so
621        // that side's cursor is omitted.
622        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                    // Page mode never resolved casts; the encode does not
627                    // need them, only the walk does.
628                    &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    /// The planner's row estimate for a filtered read, from EXPLAIN. The
654    /// exact figure stays the separate count endpoint; this exists because
655    /// counting a hot filtered set is a scan of all of it.
656    /// The exact number of rows the filter matches: a scan of the whole filtered set.
657    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    /// The SQL cast suffix for each sort column, from the table's real
694    /// columns (never a cached hint list — the aggregate lesson). The
695    /// placeholder is cast to the column's type so the bind compares
696    /// against the column without coercing the column itself.
697    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    /// One boundary row's cursor: its values for the sort columns (typed
726    /// exactly as sqlx decodes them, so a decimal keeps its scale) plus its
727    /// id. NULL in any sort column yields None: a null has no position.
728    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            // The ORM appends the id tiebreaker itself and page mode never
740            // resolves casts — without this, the uuid id decodes as text,
741            // the read fails, and the whole cursor silently vanishes.
742            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
785/// The placeholder cast for a column type, or None when a bare text bind
786/// compares correctly.
787/// The serialized fields whose value differs between `before` and `after` (never `id`), in
788/// `after`'s field order — the columns an update that started from `before` actually changes.
789/// A field `after` no longer serializes is not a change: an update cannot unset a column by
790/// leaving it out.
791pub 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
804/// AND the keyset predicate onto a built WHERE clause. The existing clause is parenthesised
805/// as a whole first: a lone group may carry a client `OR`, which would otherwise bind looser
806/// than the keyset and let rows from outside the page window through.
807fn 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        // An enum: bind text, cast to the enum's own name so the comparison
826        // runs in the enum's ordering — schema-qualified, so it resolves on a
827        // tenant whose search path does not hold the module's schema.
828        "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
836/// The cast a filter placeholder needs to compare against a column, from the column's catalog
837/// type: `format_type(atttypid, NULL)` and `pg_type.typtype`. None when a bare text bind already
838/// compares correctly (text-like columns) or when no single-value cast fits (arrays, json,
839/// composite and domain types keep today's text bind).
840fn filter_cast_for(type_name: &str, typtype: &str) -> Option<String> {
841    match typtype {
842        // An enum: cast to the enum itself so the comparison runs in the enum's ordering. The
843        // name comes from `format_type`, schema-qualified when the type is not on the search path.
844        "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
860/// Every column of `qualified_table` whose filter placeholder needs a cast, with that cast.
861///
862/// Read from `pg_catalog` rather than `information_schema`: it is a direct lookup on the
863/// relation, it says whether a user-defined type is an enum (a domain or composite must not be
864/// treated as one), and `to_regclass` resolves the name exactly as the query will. Runs through
865/// the company-scoped helper so it uses the request's own connection when one is held.
866async 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
886/// The generated hints, with the catalog's casts filling every column they do not name. A hint
887/// keeps deciding its own column, so nothing that compares correctly today changes — except that
888/// a hint naming an enum without its schema (`task_status`) gives way to the catalog's qualified
889/// name for the same type (`lifecycle.task_status`), which resolves whatever the search path.
890fn 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
906/// Turn "column ... does not exist" into a sentence that names the cause.
907///
908/// The insert names the columns the entity serializes. A field that is serialized but is not a
909/// column of the table used to vanish quietly, because selecting every column of the row type threw
910/// unknown keys away; now it fails, and the bare Postgres error does not say why. Serialized field
911/// and table column are meant to be the same set — the update path has always assumed it — so this
912/// points at the mismatch rather than leaving someone to guess.
913fn 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
925/// Quote a column name as a SQL identifier.
926///
927/// Column names here come from serializing the caller's entity, so they are Rust field names in
928/// practice — but they are interpolated into DDL/DML, where Postgres has no bind parameter for an
929/// identifier. Doubling an embedded quote is the identifier escape, so a name can never end the
930/// quoted section early.
931fn 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        // Serialize entity to JSON to extract field names and values
942        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        // Build dynamic INSERT query using jsonb_populate_record
950        // This approach handles all PostgreSQL types correctly including ENUMs and booleans
951        let json_str = serde_json::to_string(&json_obj)?;
952
953        // Name only the columns the payload actually carries.
954        //
955        // `SELECT (jsonb_populate_record(...)).*` emits EVERY column of the row type, so a column
956        // the entity does not know about arrived as an explicit NULL — and an explicit NULL is not
957        // an absent value: it overrides the column DEFAULT. That is invisible until a table's
958        // correctness depends on a default, which is exactly what composition-installed tenancy
959        // does (a scoped table defaults `org_unit_id` from the acting unit), so generic creates
960        // over such a table wrote NULL and were refused by the write-path guard.
961        //
962        // Listing the payload's own keys leaves every other column unmentioned, so its default
963        // applies. A key that is present with a JSON null is still written as NULL, which is
964        // right: the caller said so. This mirrors the update path below, which has always built
965        // its column list from these same keys.
966        let insert_columns: Vec<String> = json_obj.keys().map(|k| quote_ident(k)).collect();
967
968        let query = if insert_columns.is_empty() {
969            // Nothing supplied at all: let every column take its default rather than emitting
970            // `INSERT INTO t () SELECT`, which is not valid SQL.
971            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        // The DEFAULT VALUES form takes no bind; every other form binds the payload.
986        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        // Cast text to UUID for PostgreSQL UUID columns
1000        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// ─── Aggregation ──────────────────────────────────────────────────────────────
1064
1065/// Which reduction to apply to a column.
1066#[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    /// `SUM`/`AVG` on a text column is a type error, not a zero. `MIN`/`MAX`
1094    /// order any comparable type, so they carry no such restriction.
1095    fn requires_numeric(self) -> bool {
1096        matches!(self, AggregateFn::Sum | AggregateFn::Avg)
1097    }
1098}
1099
1100/// What to group by and what to reduce — the parsed form of the query string.
1101#[derive(Debug, Clone, Default)]
1102pub struct AggregateSpec {
1103    /// Column whose distinct values become groups. `None` asks for one total.
1104    pub group_by: Option<String>,
1105    /// `(function, column)` pairs, in the order the caller asked for them.
1106    pub reductions: Vec<(AggregateFn, String)>,
1107    /// Most groups to return before reporting the answer as truncated.
1108    pub group_limit: usize,
1109    /// Carry a label per group: the column on the group column's RELATED
1110    /// table (resolved through the entity's relation metadata) to show
1111    /// instead of a bare uuid key. `None` = keys stay as they are.
1112    pub label_field: Option<String>,
1113    /// The resolved relation behind the group column — `(target table, the
1114    /// BASE table's FK column, snake)`, filled by the generic layer from
1115    /// the entity's `relations()` metadata. Callers never set this.
1116    pub label_relation: Option<(String, String)>,
1117}
1118
1119/// The default ceiling on distinct groups.
1120///
1121/// A `group_by` on a uuid or a timestamp yields one group per row, which is a
1122/// table scan wearing a chart's clothes. Rather than refusing those columns —
1123/// a list that would be wrong for some schema sooner or later — the answer is
1124/// capped and the cap is *reported*, so a caller can tell a complete picture
1125/// from a partial one instead of quietly drawing the wrong one.
1126pub const DEFAULT_GROUP_LIMIT: usize = 200;
1127
1128/// One group's numbers. `key` is the group's value; `None` is a real answer —
1129/// the rows whose group column is null — and is distinct from "no rows".
1130#[derive(Debug, Clone, Serialize, Deserialize)]
1131pub struct AggregateGroup {
1132    pub key: Option<String>,
1133    /// The group's display label (the related row's label column), when the
1134    /// caller asked for one and the group column is a relation FK.
1135    pub label: Option<String>,
1136    pub count: u64,
1137    /// Reduction results keyed `"sum:amount"`, carried as strings.
1138    ///
1139    /// Postgres `numeric` holds more precision than an IEEE double, and money
1140    /// columns are exactly where that bites: a tenant large enough for the
1141    /// total to matter is a tenant large enough to round it. The string is the
1142    /// exact value Postgres computed; the caller decides how to parse it.
1143    /// `None` is SQL NULL — no rows contributed — which is not zero.
1144    pub values: HashMap<String, Option<String>>,
1145}
1146
1147/// Groups plus the overall total, computed together.
1148#[derive(Debug, Clone, Serialize, Deserialize)]
1149pub struct AggregateResult {
1150    pub groups: Vec<AggregateGroup>,
1151    pub total: AggregateGroup,
1152    /// True when more distinct groups exist than `group_limit` allowed.
1153    pub truncated: bool,
1154}
1155
1156/// A column name rejected by the allow-list, or a reduction that its type
1157/// cannot answer.
1158#[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
1169/// True for the Postgres types `SUM`/`AVG` accept.
1170fn 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
1182/// Read a table's real columns and their types from the catalog.
1183///
1184/// `EntityRepoMeta::column_types()` looks like the natural allow-list and is
1185/// not one: it carries only the columns the filter parser must CAST — uuids and
1186/// enums — so every numeric column is absent from it, which is exactly the set
1187/// `sum` and `avg` exist for. The catalog is the only complete and current
1188/// answer, and it cannot drift from the table the query will actually run
1189/// against.
1190async 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
1208/// Resolve a caller-supplied column name against the entity's real columns.
1209///
1210/// This is the whole defence for the aggregate path. Unlike a filter *value*,
1211/// which is bound as a parameter, a `group_by` or `sum` column is spliced into
1212/// the SQL as an identifier — binding cannot protect it. So the name is never
1213/// escaped or quoted into safety; it is *replaced* by the matching key already
1214/// present in the entity's declared column map, and a name with no match is
1215/// refused. Nothing a caller types can reach the query text.
1216fn 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    /// Group and reduce rows in one statement, under the same filters, the same
1230    /// soft-delete convention and the same tenancy fence as the list endpoint.
1231    ///
1232    /// Groups and the overall total come back from a single `GROUPING SETS`
1233    /// query, which is what lets a caller draw a chart and its headline from
1234    /// one reply, and what keeps the two numbers consistent — a separate total
1235    /// query could observe a different set of rows.
1236    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    /// [`Self::aggregate_with_filters`] under a repository-authored base condition — the same
1248    /// contract as [`Self::list_with_filters_and_base`], so an aggregate reduces exactly the
1249    /// rows the matching list returns.
1250    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        // Grouping replaces row output entirely: paging and ordering describe a
1266        // page of rows, and there are none.
1267        query_filter.limit = None;
1268        query_filter.offset = None;
1269        let (where_clause, filter_params) = query_filter.build_where_clause();
1270
1271        // Every identifier below comes from the catalog, never from the caller.
1272        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            // Cast to text in SQL so the exact value Postgres computed is what
1289            // crosses the wire — see `AggregateGroup::values`.
1290            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        // The group's label: when the caller names one (group_label) and the
1298        // group column is a relation FK of THIS entity, LEFT JOIN the
1299        // related table and carry its label column beside the key — the
1300        // aggregate's equivalent of `?include=`, which has no row to
1301        // hydrate otherwise.
1302        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                    // One total row, the groups themselves, and one more to
1340                    // detect that a further group existed.
1341                    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                // Ordered first, so it survives the cap.
1380                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        // No rows at all means no total row either: an empty result is a real
1390        // answer of zero, not a missing one.
1391        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    /// The allow-list is the entire defence, because a group/sum column is
1426    /// spliced into SQL as an identifier and cannot be bound as a parameter.
1427    #[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    /// The returned name is the map's own key, not the caller's string, so no
1445    /// caller-controlled bytes can reach the SQL even on a match.
1446    #[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        // Ordering works on any comparable column, so these stay open.
1458        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        // A domain or composite is not an enum, even though both are user-defined.
1502        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        // The bare enum hint gives way to the catalog's qualified name for the same type: a
1530        // tenant's search path need not hold the module's schema.
1531        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        // A uuid hint resolves anywhere; nothing to read.
1561        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}