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    /// The most rows one page may hold. Every list the generic repositories serve is capped
42    /// here, whatever page size the caller asked for.
43    pub const MAX_PER_PAGE: u32 = 100;
44
45    /// A page request, normalized: page 0 reads as page 1, and the page size is held to
46    /// `1..=`[`MAX_PER_PAGE`](Self::MAX_PER_PAGE). Build pagination through this rather than
47    /// a struct literal, which skips both.
48    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    /// The rows to skip. Page 0 counts as page 1, so a literal that skipped [`Self::new`]
56    /// still never underflows.
57    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/// Sorting parameters
67#[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/// Filter parameters
81#[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/// Paginated result wrapper
99#[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    /// Keyset paging: the position of this page's last row, for the next
112    /// page's `after=`. None when the page is empty or no more rows follow.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub next_cursor: Option<String>,
115    /// Keyset paging: the position of this page's first row, for the
116    /// previous page's `before=`.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub prev_cursor: Option<String>,
119    /// Whether another page follows (fetched with limit+1, so it is known
120    /// without a count).
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub has_more: Option<bool>,
123    /// How `total` came to be: "exact" (counted), "estimate" (the planner's
124    /// row estimate — `estimate=1` asked for it), or "none" (a cursor walk
125    /// without an estimate; the exact figure is the separate count call).
126    #[serde(default = "default_count_mode")]
127    pub count_mode: String,
128}
129
130/// Below this many rows (by the planner's estimate) an estimated total is counted exactly
131/// instead: the count is cheap, and a planner estimate is least reliable on small sets.
132pub 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/// Database operations trait - requires Serialize for write operations
155#[async_trait]
156pub trait DatabaseOperations<T: for<'a> FromRow<'a, PgRow> + Send + Unpin> {
157    /// Create a new entity
158    async fn create(&self, entity: &T) -> anyhow::Result<T>;
159
160    /// Find entity by ID
161    async fn find_by_id(&self, id: &str) -> anyhow::Result<Option<T>>;
162
163    /// Find all entities
164    async fn find_all(&self) -> anyhow::Result<Vec<T>>;
165
166    /// Update an existing entity
167    async fn update(&self, id: &str, entity: &T) -> anyhow::Result<Option<T>>;
168
169    /// Delete an entity
170    async fn delete(&self, id: &str) -> anyhow::Result<bool>;
171
172    /// Count all entities
173    async fn count(&self) -> anyhow::Result<u64>;
174
175    /// Check if entity exists
176    async fn exists(&self, id: &str) -> anyhow::Result<bool>;
177
178    /// Execute custom query
179    async fn execute_query(&self, query: &str) -> anyhow::Result<u64>;
180}
181
182/// PostgreSQL repository implementation with JSON-based dynamic queries
183pub 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    /// List entities with pagination and advanced filtering
207    ///
208    /// This method provides comprehensive filtering capabilities similar to Laravel's Filter Query String.
209    ///
210    /// # Supported Filter Operators
211    ///
212    /// - `field[eq]=value` - Equal
213    /// - `field[notEq]=value` - Not equal
214    /// - `field[gt]=value` - Greater than
215    /// - `field[gte]=value` - Greater than or equal
216    /// - `field[lt]=value` - Less than
217    /// - `field[lte]=value` - Less than or equal
218    /// - `field[like]=value` - LIKE (case-sensitive)
219    /// - `field[ilike]=value` - ILIKE (case-insensitive)
220    /// - `field[notlike]=value` - NOT LIKE
221    /// - `field[contain]=value` - Contains (%value%)
222    /// - `field[notcontain]=value` - Does not contain
223    /// - `field[startwith]=value` - Starts with (value%)
224    /// - `field[endwith]=value` - Ends with (%value)
225    /// - `field[in]=val1,val2` - IN array
226    /// - `field[notin]=val1,val2` - NOT IN array
227    /// - `field[between]=val1,val2` - BETWEEN
228    /// - `field[notbetween]=val1,val2` - NOT BETWEEN
229    /// - `field[isnull]` - IS NULL
230    /// - `field[isnotnull]` - IS NOT NULL
231    ///
232    /// # Special Parameters
233    ///
234    /// - `search=value&searchFields=field1,field2` - Search in multiple fields
235    /// - `orderby=field` or `orderby[field]=asc` - Sort results
236    /// - `limit=10` - Limit results
237    /// - `page=1` - Page number
238    ///
239    /// # Column Type Casting
240    ///
241    /// The `column_types` HashMap maps field names to their PostgreSQL types for proper casting.
242    /// For example, `{"status": "user_status"}` will cast the status parameter to `user_status` enum type.
243    ///
244    /// # Example
245    ///
246    /// ```ignore
247    /// let mut filters = HashMap::new();
248    /// filters.insert("username[contain]".to_string(), "john".to_string());
249    /// filters.insert("age[gt]".to_string(), "18".to_string());
250    ///
251    /// let mut column_types = HashMap::new();
252    /// column_types.insert("status".to_string(), "user_status".to_string());
253    ///
254    /// let result = repo.list_with_filters(
255    ///     PaginationParams::new(1, 10),
256    ///     &filters,
257    ///     &column_types,
258    ///     &["username", "email"]  // search fields
259    /// ).await?;
260    /// ```
261    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    /// [`Self::list_with_filters`] under a repository-authored base condition.
276    ///
277    /// `base_condition` is raw SQL ANDed, as its own parenthesised group, ahead of everything
278    /// the request asked for. It must come from code (a soft-delete guard, the company fence),
279    /// never from the request — which is why it is a separate argument and not a filter key:
280    /// the wire parser ignores every `__`-prefixed key.
281    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        // Parse filters from HashMap (no field allow-list by default for backward compatibility)
293        let mut query_filter = self.parse_typed_filters(filters, column_types, None).await?;
294        if let Some(cond) = base_condition {
295            query_filter.add_base_condition(cond);
296        }
297
298        // Set up search fields if provided
299        if !search_fields.is_empty() {
300            query_filter.search_fields = search_fields.iter().map(|s| s.to_string()).collect();
301        }
302
303        self.execute_list(pagination, query_filter).await
304    }
305
306    /// List entities with pagination, filtering, and field whitelist enforcement
307    ///
308    /// Similar to `list_with_filters` but accepts an optional set of allowed field names.
309    /// When provided, only filter conditions on whitelisted fields are applied;
310    /// conditions on unknown fields are silently dropped.
311    ///
312    /// This prevents clients from filtering on internal or sensitive columns
313    /// (e.g., `password_hash`, `internal_notes`).
314    ///
315    /// # Arguments
316    ///
317    /// * `pagination` - Page and limit parameters
318    /// * `filters` - HTTP query parameters (e.g., `field[operator]=value`)
319    /// * `column_types` - PostgreSQL type mappings for enum casting
320    /// * `search_fields` - Fields to search when `search` parameter is present
321    /// * `allowed_fields` - Optional whitelist of field names; `None` allows all fields
322    ///
323    /// # Example
324    ///
325    /// ```ignore
326    /// let allowed: HashSet<String> = ["username", "email", "status"]
327    ///     .iter().map(|s| s.to_string()).collect();
328    ///
329    /// let result = repo.list_with_filters_whitelisted(
330    ///     PaginationParams::new(1, 10),
331    ///     &filters,
332    ///     &column_types,
333    ///     &["username", "email"],
334    ///     Some(&allowed),
335    /// ).await?;
336    /// ```
337    pub async fn list_with_filters_whitelisted(
338        &self,
339        pagination: PaginationParams,
340        filters: &HashMap<String, String>,
341        column_types: &HashMap<String, String>,
342        search_fields: &[&str],
343        allowed_fields: Option<&HashSet<String>>,
344    ) -> anyhow::Result<PaginatedResult<T>>
345    where
346        T: Send + Sync,
347    {
348        // Parse filters with optional field whitelist
349        let mut query_filter =
350            self.parse_typed_filters(filters, column_types, allowed_fields).await?;
351
352        // Set up search fields if provided
353        if !search_fields.is_empty() {
354            query_filter.search_fields = search_fields.iter().map(|s| s.to_string()).collect();
355        }
356
357        self.execute_list(pagination, query_filter).await
358    }
359
360    /// Update one row by id, setting only `only` columns when given (every serialized column but
361    /// `id` otherwise), and only while `guard` holds.
362    ///
363    /// `guard` is repository-authored SQL ANDed into the `WHERE` after the id match (`""` for
364    /// none), written against the table alias `t` — e.g. `" AND t.metadata->>'deleted_at' IS
365    /// NULL"`. Putting the not-deleted predicate in the UPDATE itself is what keeps a write that
366    /// races a soft delete from reviving the row: a separate existence check before the UPDATE
367    /// leaves a window in which the delete lands. `Ok(None)` when no row matched.
368    ///
369    /// With `only` empty nothing is written, and the current row (under the same guard) is
370    /// returned unchanged.
371    pub async fn update_guarded(
372        &self,
373        id: &str,
374        entity: &T,
375        only: Option<&[String]>,
376        guard: &str,
377    ) -> anyhow::Result<Option<T>>
378    where
379        T: Serialize + Send + Sync,
380    {
381        let result = match update_statement(&self.table_name, entity, only, guard)? {
382            UpdateStatement::Unchanged(query) => {
383                crate::company_scope::fetch_optional_scoped(
384                    &self.pool,
385                    sqlx::query_as::<_, T>(&query).bind(id),
386                )
387                .await?
388            }
389            UpdateStatement::Update { query, json } => {
390                crate::company_scope::fetch_optional_scoped(
391                    &self.pool,
392                    sqlx::query_as::<_, T>(&query).bind(json).bind(id),
393                )
394                .await?
395            }
396        };
397
398        Ok(result)
399    }
400
401    /// Parse the wire filters with a cast for every typed column they compare.
402    ///
403    /// Filter values arrive as text and are bound as text; PostgreSQL has no implicit comparison
404    /// between text and a boolean, number, uuid, date, time or timestamp column, so each such
405    /// comparison needs a cast on its placeholder. The entity's generated `column_types()` hints
406    /// supply it where they exist, and stay the answer for the columns they name. They were never
407    /// a complete list — no booleans or numbers, only uuids named `id`/`*_id`, and temporal
408    /// columns only in modules generated after the generator learned them — so whenever a
409    /// comparison is left without a cast, the table's real column types are read from the
410    /// catalog and fill the gaps. The catalog cannot drift from the table the query runs
411    /// against, which is the same reason the aggregate and sort paths read it.
412    ///
413    /// A filter on text columns only, or with hints for every compared column, costs nothing
414    /// extra beyond the parse; otherwise one catalog lookup on the request's own connection.
415    async fn parse_typed_filters(
416        &self,
417        filters: &HashMap<String, String>,
418        column_types: &HashMap<String, String>,
419        allowed_fields: Option<&HashSet<String>>,
420    ) -> anyhow::Result<crate::QueryFilter> {
421        let query_filter = parse_query_filter(filters, column_types, allowed_fields)?;
422        if !query_filter.needs_catalog_types() {
423            return Ok(query_filter);
424        }
425        let catalog = catalog_filter_casts(&self.pool, &self.table_name).await?;
426        let merged = merge_filter_casts(column_types, catalog);
427        parse_query_filter(filters, &merged, allowed_fields)
428    }
429
430    /// The shared list execution: filters, order, paging, and the total.
431    ///
432    /// Three shapes, chosen by the request:
433    ///
434    /// * **page mode** (today's behaviour, unchanged): exact COUNT, then
435    ///   `LIMIT l OFFSET o` in the requested order;
436    /// * **cursor mode** (`after=`/`before=`): the keyset predicate replaces
437    ///   the offset, the order always ends on the `id` tiebreaker, and one
438    ///   extra row is fetched so `has_more` is known without a count — the
439    ///   page costs the same at any depth, which is the point;
440    /// * **estimate** (`estimate=1`, either mode): the exact COUNT (a scan
441    ///   of the whole filtered set) is replaced by the planner's row
442    ///   estimate; the exact figure stays the separate count call.
443    #[allow(clippy::type_complexity)]
444    async fn execute_list(
445        &self,
446        pagination: PaginationParams,
447        query_filter: crate::QueryFilter,
448    ) -> anyhow::Result<PaginatedResult<T>> {
449        let limit = pagination.limit() as i64;
450        let backwards =
451            query_filter.cursor_before.is_some() && query_filter.cursor_after.is_none();
452        let cursor_walk = query_filter.cursor_after.is_some() || backwards;
453
454        let (mut where_clause, mut filter_params) = query_filter.build_where_clause();
455        let order_clause;
456        // Cast suffixes for the sort columns, for the keyset binds.
457        let mut boundary_casts: Vec<Option<String>> = Vec::new();
458
459        // The deterministic order: a cursor walks one, and a page-mode list
460        // that carries a sort gets the same treatment so it can HAND OUT a
461        // cursor to start a keyset walk from. Appending the id tiebreaker
462        // only reorders rows that tied — ties had no order to preserve.
463        let mut sorts: Vec<(String, FilterSortDirection)> = query_filter
464            .sorts
465            .iter()
466            .map(|s| (s.field.clone(), s.direction.clone()))
467            .collect();
468        if cursor_walk || !sorts.is_empty() {
469            if sorts.is_empty() {
470                sorts.push(("id".into(), FilterSortDirection::Asc));
471            } else if sorts.last().map(|(f, _)| f != "id").unwrap_or(true) {
472                sorts.push(("id".into(), FilterSortDirection::Asc));
473            }
474        }
475
476        // Casts whenever there is a deterministic order to key: cursor mode
477        // walks on them, page mode encodes the handed-out cursor from them.
478        if !sorts.is_empty() {
479            boundary_casts = self.sort_column_casts(&sorts).await?;
480        }
481
482        if cursor_walk {
483            let opaque = if backwards {
484                query_filter.cursor_before.clone().unwrap()
485            } else {
486                query_filter.cursor_after.clone().unwrap()
487            };
488            let payload = crate::filter::cursor::decode_cursor(&opaque, &sorts)
489                .map_err(|e| anyhow::anyhow!("cursor refused: {e}"))?;
490            let mut idx = filter_params.len() + 1;
491            let (keyset_sql, keyset_params) = crate::filter::cursor::build_keyset_predicate(
492                &payload,
493                &mut idx,
494                &boundary_casts,
495                backwards,
496            );
497            where_clause = and_keyset_predicate(&where_clause, &keyset_sql);
498            filter_params.extend(keyset_params);
499            let parts: Vec<String> = sorts
500                .iter()
501                .map(|(f, d)| {
502                    let dir = if (*d == FilterSortDirection::Desc) != backwards {
503                        "DESC"
504                    } else {
505                        "ASC"
506                    };
507                    format!("{} {}", f, dir)
508                })
509                .collect();
510            order_clause = format!(" ORDER BY {}", parts.join(", "));
511        } else {
512            // Page mode with a sort: the caller's order, made deterministic
513            // by the same id tiebreaker so the cursor it hands out is real.
514            if sorts.is_empty() {
515                order_clause = query_filter.build_order_by_clause();
516            } else {
517                let parts: Vec<String> = sorts
518                    .iter()
519                    .map(|(f, d)| {
520                        let dir =
521                            if *d == FilterSortDirection::Desc { "DESC" } else { "ASC" };
522                        format!("{} {}", f, dir)
523                    })
524                    .collect();
525                order_clause = format!(" ORDER BY {}", parts.join(", "));
526            }
527        }
528        // The deterministic order a cursor walks in (cursor mode only).
529        let boundary_sorts: Vec<(String, FilterSortDirection)> = sorts;
530
531        // The total: exact in page mode (today's behaviour), the planner's
532        // estimate when asked, nothing on a cursor walk that did not ask.
533        // Asked for an estimate, a set the planner expects to be small is
534        // counted all the same: the count is cheap there and the estimate is
535        // at its least reliable, so "about 12" never stands in for 10.
536        let (total, count_mode) = if query_filter.estimate_total {
537            let estimate = self.estimate_filtered_rows(&where_clause, &filter_params).await?;
538            if estimate <= EXACT_COUNT_BELOW {
539                (self.count_filtered_rows(&where_clause, &filter_params).await?, "exact")
540            } else {
541                (estimate, "estimate")
542            }
543        } else if cursor_walk {
544            (0u64, "none")
545        } else {
546            (self.count_filtered_rows(&where_clause, &filter_params).await?, "exact")
547        };
548
549        // The page: limit+1 rows so has_more is known without a count. The
550        // extra row is truncated away before returning.
551        let fetch = limit + 1;
552        let data_query = if cursor_walk {
553            format!(
554                "SELECT * FROM {}{}{} LIMIT {}",
555                self.table_name, where_clause, order_clause, fetch
556            )
557        } else {
558            format!(
559                "SELECT * FROM {}{}{} LIMIT {} OFFSET {}",
560                self.table_name,
561                where_clause,
562                order_clause,
563                fetch,
564                pagination.offset()
565            )
566        };
567
568        let mut pagination_info = PaginationInfo::new(pagination.page, pagination.per_page, total);
569        pagination_info.count_mode = count_mode.to_string();
570
571        // One fetch path for both modes: untyped rows, decoded per-entity
572        // through FromRow exactly as query_as would (decimals keep their
573        // scale), with the boundary values read for the cursors off the same
574        // rows — no second query, no JSON round-trip.
575        let mut rows_query = sqlx::query(&data_query);
576        for param in &filter_params {
577            rows_query = rows_query.bind(param);
578        }
579        let rows: Vec<PgRow> =
580            crate::company_scope::fetch_all_rows_scoped(&self.pool, rows_query).await?;
581        let has_more = rows.len() as i64 > limit;
582        let mut page: Vec<PgRow> = rows.into_iter().take(limit as usize).collect();
583        if backwards {
584            page.reverse();
585        }
586        let data: anyhow::Result<Vec<T>> = page
587            .iter()
588            .map(|row| T::from_row(row).map_err(|e| anyhow::anyhow!("decode row: {e}")))
589            .collect();
590        let data = data?;
591
592        // Cursors from the boundary rows' own values, whenever the order is
593        // deterministic. A NULL in a sort column cannot key a position, so
594        // that side's cursor is omitted.
595        let deterministic = !boundary_sorts.is_empty();
596        let next_cursor = if (has_more || backwards) && deterministic {
597            page.last().and_then(|r| {
598                let casts = if boundary_casts.is_empty() {
599                    // Page mode never resolved casts; the encode does not
600                    // need them, only the walk does.
601                    &boundary_casts
602                } else {
603                    &boundary_casts
604                };
605                self.row_cursor(r, &boundary_sorts, casts)
606            })
607        } else {
608            None
609        };
610        let prev_cursor = if !page.is_empty() && deterministic {
611            page.first().and_then(|r| self.row_cursor(r, &boundary_sorts, &boundary_casts))
612        } else {
613            None
614        };
615
616        pagination_info.has_more = Some(has_more);
617        pagination_info.next_cursor = next_cursor;
618        pagination_info.prev_cursor = prev_cursor;
619
620        Ok(PaginatedResult {
621            data,
622            pagination: pagination_info,
623        })
624    }
625
626    /// The planner's row estimate for a filtered read, from EXPLAIN. The
627    /// exact figure stays the separate count endpoint; this exists because
628    /// counting a hot filtered set is a scan of all of it.
629    /// The exact number of rows the filter matches: a scan of the whole filtered set.
630    async fn count_filtered_rows(
631        &self,
632        where_clause: &str,
633        filter_params: &[String],
634    ) -> anyhow::Result<u64> {
635        let count_query = format!("SELECT COUNT(*) FROM {}{}", self.table_name, where_clause);
636        let mut count_query_builder = sqlx::query_scalar::<_, i64>(&count_query);
637        for param in filter_params {
638            count_query_builder = count_query_builder.bind(param);
639        }
640        Ok(crate::company_scope::fetch_one_scalar_scoped(&self.pool, count_query_builder).await?
641            as u64)
642    }
643
644    async fn estimate_filtered_rows(
645        &self,
646        where_clause: &str,
647        filter_params: &[String],
648    ) -> anyhow::Result<u64> {
649        let explain = format!("EXPLAIN (FORMAT JSON) SELECT 1 FROM {}{}", self.table_name, where_clause);
650        let mut builder = sqlx::query_scalar::<_, serde_json::Value>(&explain);
651        for param in filter_params {
652            builder = builder.bind(param);
653        }
654        let plan: serde_json::Value =
655            crate::company_scope::fetch_one_scalar_scoped(&self.pool, builder).await?;
656        let rows = plan
657            .as_array()
658            .and_then(|a| a.first())
659            .and_then(|top| top.get("Plan"))
660            .and_then(|p| p.get("Plan Rows"))
661            .and_then(|r| r.as_i64())
662            .unwrap_or(0);
663        Ok(rows.max(0) as u64)
664    }
665
666    /// The SQL cast suffix for each sort column, from the table's real
667    /// columns (never a cached hint list — the aggregate lesson). The
668    /// placeholder is cast to the column's type so the bind compares
669    /// against the column without coercing the column itself.
670    async fn sort_column_casts(
671        &self,
672        sorts: &[(String, FilterSortDirection)],
673    ) -> anyhow::Result<Vec<Option<String>>> {
674        let (schema, table) = match self.table_name.rsplit_once('.') {
675            Some((s, t)) => (s.to_string(), t.to_string()),
676            None => ("public".to_string(), self.table_name.clone()),
677        };
678        let mut casts: Vec<Option<String>> = Vec::with_capacity(sorts.len());
679        for (field, _) in sorts {
680            let row: Option<(String, String, String)> = sqlx::query_as(
681                "SELECT data_type, coalesce(udt_name, ''), coalesce(udt_schema, '') \
682                   FROM information_schema.columns \
683                  WHERE table_schema = $1 AND table_name = $2 AND column_name = $3",
684            )
685            .bind(&schema)
686            .bind(&table)
687            .bind(field)
688            .fetch_optional(&self.pool)
689            .await?;
690            let cast = row
691                .map(|(data_type, udt, udt_schema)| cast_suffix(&data_type, &udt, &udt_schema))
692                .flatten();
693            casts.push(cast);
694        }
695        Ok(casts)
696    }
697
698    /// One boundary row's cursor: its values for the sort columns (typed
699    /// exactly as sqlx decodes them, so a decimal keeps its scale) plus its
700    /// id. NULL in any sort column yields None: a null has no position.
701    fn row_cursor(
702        &self,
703        row: &PgRow,
704        sorts: &[(String, FilterSortDirection)],
705        casts: &[Option<String>],
706    ) -> Option<String> {
707        let id: uuid::Uuid = row.try_get("id").ok()?;
708        let mut values: Vec<serde_json::Value> = Vec::with_capacity(sorts.len());
709        for (i, (field, _)) in sorts.iter().enumerate() {
710            let field = field.as_str();
711            let mut data_type = casts.get(i).and_then(|c| c.as_deref()).unwrap_or("");
712            // The ORM appends the id tiebreaker itself and page mode never
713            // resolves casts — without this, the uuid id decodes as text,
714            // the read fails, and the whole cursor silently vanishes.
715            if field == "id" && data_type.is_empty() {
716                data_type = "uuid";
717            }
718            let text: Option<String> = match data_type {
719                "numeric" => row
720                    .try_get::<Option<sqlx::types::Decimal>, _>(field)
721                    .ok()?
722                    .map(|d| d.to_string()),
723                "uuid" => row
724                    .try_get::<Option<uuid::Uuid>, _>(field)
725                    .ok()?
726                    .map(|u| u.to_string()),
727                "timestamptz" => row
728                    .try_get::<Option<chrono::DateTime<chrono::Utc>>, _>(field)
729                    .ok()?
730                    .map(|t| t.to_rfc3339()),
731                "integer" | "smallint" => row
732                    .try_get::<Option<i32>, _>(field)
733                    .ok()?
734                    .map(|n| n.to_string()),
735                "bigint" => row
736                    .try_get::<Option<i64>, _>(field)
737                    .ok()?
738                    .map(|n| n.to_string()),
739                "boolean" => row
740                    .try_get::<Option<bool>, _>(field)
741                    .ok()?
742                    .map(|b| b.to_string()),
743                "date" => row
744                    .try_get::<Option<chrono::NaiveDate>, _>(field)
745                    .ok()?
746                    .map(|d| d.to_string()),
747                _ => row
748                    .try_get::<Option<String>, _>(field)
749                    .ok()?
750                    .filter(|s| !s.is_empty() || data_type.is_empty()),
751            };
752            values.push(serde_json::Value::String(text?));
753        }
754        crate::filter::cursor::encode_cursor(sorts, &values, &id.to_string()).ok()
755    }
756}
757
758/// The placeholder cast for a column type, or None when a bare text bind
759/// compares correctly.
760/// The SQL of one guarded row update — see [`PostgresRepository::update_guarded`].
761pub(crate) enum UpdateStatement {
762    /// Nothing to write: re-read the row under the guard. Binds `$1` = id.
763    Unchanged(String),
764    /// The UPDATE. Binds `$1` = the row as JSON, `$2` = id.
765    Update { query: String, json: String },
766}
767
768/// Build the guarded update of `entity` in `table`, setting only `only` columns when given.
769pub(crate) fn update_statement<T: Serialize>(
770    table: &str,
771    entity: &T,
772    only: Option<&[String]>,
773    guard: &str,
774) -> anyhow::Result<UpdateStatement> {
775    let json_obj = match serde_json::to_value(entity)? {
776        Value::Object(obj) => obj,
777        _ => return Err(anyhow::anyhow!("Entity must serialize to a JSON object")),
778    };
779
780    let update_columns: Vec<&String> = json_obj
781        .keys()
782        .filter(|k| *k != "id")
783        .filter(|k| only.is_none_or(|cols| cols.iter().any(|c| c == *k)))
784        .collect();
785
786    if update_columns.is_empty() {
787        return Ok(UpdateStatement::Unchanged(format!(
788            "SELECT t.* FROM {table} AS t WHERE t.id = $1::uuid{guard}"
789        )));
790    }
791
792    let column_names = update_columns
793        .iter()
794        .map(|k| quote_ident(k))
795        .collect::<Vec<_>>()
796        .join(", ");
797    let json = serde_json::to_string(&json_obj)?;
798
799    // jsonb_populate_record in a CTE gives every value its column's real type (enums,
800    // booleans, numerics) without per-column casts.
801    let query = format!(
802        r#"
803            WITH new_row AS (
804                SELECT (jsonb_populate_record(NULL::{table}, $1::jsonb)).*
805            )
806            UPDATE {table} AS t
807            SET ({columns}) = (SELECT {columns} FROM new_row)
808            WHERE t.id = $2::uuid{guard}
809            RETURNING t.*
810            "#,
811        table = table,
812        columns = column_names,
813        guard = guard,
814    );
815    Ok(UpdateStatement::Update { query, json })
816}
817
818/// The SQL of one insert: the query, and the JSON payload it binds as `$1` (`None` for the
819/// `DEFAULT VALUES` form, which binds nothing).
820pub(crate) fn insert_statement<T: Serialize>(
821    table: &str,
822    entity: &T,
823) -> anyhow::Result<(String, Option<String>)> {
824    let json_obj = match serde_json::to_value(entity)? {
825        Value::Object(obj) => obj,
826        _ => return Err(anyhow::anyhow!("Entity must serialize to a JSON object")),
827    };
828
829    // Name only the columns the payload actually carries.
830    //
831    // `SELECT (jsonb_populate_record(...)).*` emits EVERY column of the row type, so a column
832    // the entity does not know about arrived as an explicit NULL — and an explicit NULL is not
833    // an absent value: it overrides the column DEFAULT. That is invisible until a table's
834    // correctness depends on a default, which is exactly what composition-installed tenancy
835    // does (a scoped table defaults `org_unit_id` from the acting unit), so generic creates
836    // over such a table wrote NULL and were refused by the write-path guard.
837    //
838    // Listing the payload's own keys leaves every other column unmentioned, so its default
839    // applies. A key that is present with a JSON null is still written as NULL, which is
840    // right: the caller said so. This mirrors the update path, which has always built its
841    // column list from these same keys.
842    let insert_columns: Vec<String> = json_obj.keys().map(|k| quote_ident(k)).collect();
843
844    if insert_columns.is_empty() {
845        // Nothing supplied at all: let every column take its default rather than emitting
846        // `INSERT INTO t () SELECT`, which is not valid SQL.
847        return Ok((format!("INSERT INTO {table} DEFAULT VALUES RETURNING *"), None));
848    }
849    let columns = insert_columns.join(", ");
850    let query = format!(
851        r#"
852            INSERT INTO {table} ({columns})
853            SELECT {columns} FROM jsonb_populate_record(NULL::{table}, $1::jsonb)
854            RETURNING *
855            "#,
856    );
857    Ok((query, Some(serde_json::to_string(&json_obj)?)))
858}
859
860/// The serialized fields whose value differs between `before` and `after` (never `id`), in
861/// `after`'s field order — the columns an update that started from `before` actually changes.
862/// A field `after` no longer serializes is not a change: an update cannot unset a column by
863/// leaving it out.
864pub fn changed_columns<T: Serialize>(before: &T, after: &T) -> anyhow::Result<Vec<String>> {
865    let (Value::Object(before), Value::Object(after)) =
866        (serde_json::to_value(before)?, serde_json::to_value(after)?)
867    else {
868        return Err(anyhow::anyhow!("Entity must serialize to a JSON object"));
869    };
870    Ok(after
871        .into_iter()
872        .filter(|(k, v)| k != "id" && before.get(k) != Some(v))
873        .map(|(k, _)| k)
874        .collect())
875}
876
877/// AND the keyset predicate onto a built WHERE clause. The existing clause is parenthesised
878/// as a whole first: a lone group may carry a client `OR`, which would otherwise bind looser
879/// than the keyset and let rows from outside the page window through.
880fn and_keyset_predicate(where_clause: &str, keyset_sql: &str) -> String {
881    match where_clause.trim_start().strip_prefix("WHERE ") {
882        None => format!(" WHERE {}", keyset_sql),
883        Some(existing) => format!(" WHERE ({}) AND ({})", existing, keyset_sql),
884    }
885}
886
887fn cast_suffix(data_type: &str, udt_name: &str, udt_schema: &str) -> Option<String> {
888    match data_type {
889        "uuid" => Some("uuid".into()),
890        "numeric" => Some("numeric".into()),
891        "integer" => Some("integer".into()),
892        "smallint" => Some("smallint".into()),
893        "bigint" => Some("bigint".into()),
894        "boolean" => Some("boolean".into()),
895        "date" => Some("date".into()),
896        "timestamp with time zone" => Some("timestamptz".into()),
897        "timestamp without time zone" => Some("timestamp".into()),
898        // An enum: bind text, cast to the enum's own name so the comparison
899        // runs in the enum's ordering — schema-qualified, so it resolves on a
900        // tenant whose search path does not hold the module's schema.
901        "USER-DEFINED" if !udt_name.is_empty() => Some(match udt_schema {
902            "" | "public" | "pg_catalog" => udt_name.to_string(),
903            schema => format!("{schema}.{udt_name}"),
904        }),
905        _ => None,
906    }
907}
908
909/// The cast a filter placeholder needs to compare against a column, from the column's catalog
910/// type: `format_type(atttypid, NULL)` and `pg_type.typtype`. None when a bare text bind already
911/// compares correctly (text-like columns) or when no single-value cast fits (arrays, json,
912/// composite and domain types keep today's text bind).
913fn filter_cast_for(type_name: &str, typtype: &str) -> Option<String> {
914    match typtype {
915        // An enum: cast to the enum itself so the comparison runs in the enum's ordering. The
916        // name comes from `format_type`, schema-qualified when the type is not on the search path.
917        "e" => Some(type_name.to_string()),
918        "b" => match type_name {
919            "uuid" | "boolean" | "smallint" | "integer" | "bigint" | "numeric" | "real"
920            | "double precision" | "date" | "interval" | "inet" | "cidr" | "macaddr" => {
921                Some(type_name.to_string())
922            }
923            "time without time zone" => Some("time".into()),
924            "time with time zone" => Some("timetz".into()),
925            "timestamp with time zone" => Some("timestamptz".into()),
926            "timestamp without time zone" => Some("timestamp".into()),
927            _ => None,
928        },
929        _ => None,
930    }
931}
932
933/// Every column of `qualified_table` whose filter placeholder needs a cast, with that cast.
934///
935/// Read from `pg_catalog` rather than `information_schema`: it is a direct lookup on the
936/// relation, it says whether a user-defined type is an enum (a domain or composite must not be
937/// treated as one), and `to_regclass` resolves the name exactly as the query will. Runs through
938/// the company-scoped helper so it uses the request's own connection when one is held.
939async fn catalog_filter_casts(
940    pool: &PgPool,
941    qualified_table: &str,
942) -> anyhow::Result<HashMap<String, String>> {
943    let q = sqlx::query_as::<Postgres, (String, String, String)>(
944        "SELECT a.attname::text, format_type(a.atttypid, NULL), t.typtype::text
945           FROM pg_catalog.pg_attribute a
946           JOIN pg_catalog.pg_type t ON t.oid = a.atttypid
947          WHERE a.attrelid = to_regclass($1) AND a.attnum > 0 AND NOT a.attisdropped",
948    )
949    .bind(qualified_table.to_string());
950    let rows = crate::company_scope::fetch_all_scoped(pool, q).await?;
951    Ok(rows
952        .into_iter()
953        .filter_map(|(column, type_name, typtype)| {
954            filter_cast_for(&type_name, &typtype).map(|cast| (column, cast))
955        })
956        .collect())
957}
958
959/// The generated hints, with the catalog's casts filling every column they do not name. A hint
960/// keeps deciding its own column, so nothing that compares correctly today changes — except that
961/// a hint naming an enum without its schema (`task_status`) gives way to the catalog's qualified
962/// name for the same type (`lifecycle.task_status`), which resolves whatever the search path.
963fn merge_filter_casts(
964    hints: &HashMap<String, String>,
965    mut catalog: HashMap<String, String>,
966) -> HashMap<String, String> {
967    for (column, cast) in hints {
968        let qualified_same_type = catalog
969            .get(column)
970            .and_then(|c| c.rsplit_once('.'))
971            .is_some_and(|(_, name)| name.trim_matches('"') == cast.as_str());
972        if !qualified_same_type {
973            catalog.insert(column.clone(), cast.clone());
974        }
975    }
976    catalog
977}
978
979/// Turn "column ... does not exist" into a sentence that names the cause.
980///
981/// The insert names the columns the entity serializes. A field that is serialized but is not a
982/// column of the table used to vanish quietly, because selecting every column of the row type threw
983/// unknown keys away; now it fails, and the bare Postgres error does not say why. Serialized field
984/// and table column are meant to be the same set — the update path has always assumed it — so this
985/// points at the mismatch rather than leaving someone to guess.
986pub(crate) fn explain_unknown_column(error: sqlx::Error, table: &str) -> anyhow::Error {
987    let text = error.to_string();
988    if text.contains("does not exist") && text.contains("column") {
989        return anyhow::Error::new(error).context(format!(
990            "insert into {table} named a column that does not exist: the entity serializes a field \
991             with no matching column. Every serialized field must be a column of the table (rename \
992             it, map it with #[serde(rename)], or skip it with #[serde(skip)])"
993        ));
994    }
995    anyhow::Error::new(error)
996}
997
998/// Quote a column name as a SQL identifier.
999///
1000/// Column names here come from serializing the caller's entity, so they are Rust field names in
1001/// practice — but they are interpolated into DDL/DML, where Postgres has no bind parameter for an
1002/// identifier. Doubling an embedded quote is the identifier escape, so a name can never end the
1003/// quoted section early.
1004fn quote_ident(name: &str) -> String {
1005    format!("\"{}\"", name.replace('"', "\"\""))
1006}
1007
1008#[async_trait]
1009impl<T> DatabaseOperations<T> for PostgresRepository<T>
1010where
1011    T: for<'a> FromRow<'a, PgRow> + Send + Sync + Unpin + Serialize,
1012{
1013    async fn create(&self, entity: &T) -> anyhow::Result<T> {
1014        let (query, json) = insert_statement(&self.table_name, entity)?;
1015        // The DEFAULT VALUES form takes no bind; every other form binds the payload.
1016        let statement = match json {
1017            Some(json) => sqlx::query_as::<_, T>(&query).bind(json),
1018            None => sqlx::query_as::<_, T>(&query),
1019        };
1020        let result = crate::company_scope::fetch_one_scoped(&self.pool, statement)
1021            .await
1022            .map_err(|e| explain_unknown_column(e, &self.table_name))?;
1023
1024        Ok(result)
1025    }
1026
1027    async fn find_by_id(&self, id: &str) -> anyhow::Result<Option<T>> {
1028        // Cast text to UUID for PostgreSQL UUID columns
1029        let query = format!("SELECT * FROM {} WHERE id = $1::uuid", self.table_name);
1030        let result = crate::company_scope::fetch_optional_scoped(
1031            &self.pool,
1032            sqlx::query_as::<Postgres, T>(&query).bind(id),
1033        )
1034        .await?;
1035        Ok(result)
1036    }
1037
1038    async fn find_all(&self) -> anyhow::Result<Vec<T>> {
1039        let query = format!("SELECT * FROM {}", self.table_name);
1040        let results = crate::company_scope::fetch_all_scoped(
1041            &self.pool,
1042            sqlx::query_as::<Postgres, T>(&query),
1043        )
1044        .await?;
1045        Ok(results)
1046    }
1047
1048    async fn update(&self, id: &str, entity: &T) -> anyhow::Result<Option<T>> {
1049        self.update_guarded(id, entity, None, "").await
1050    }
1051
1052    async fn delete(&self, id: &str) -> anyhow::Result<bool> {
1053        let query = format!("DELETE FROM {} WHERE id = $1::uuid", self.table_name);
1054        let result = crate::company_scope::execute_scoped(
1055            &self.pool,
1056            sqlx::query(&query).bind(id),
1057        )
1058        .await?;
1059        Ok(result.rows_affected() > 0)
1060    }
1061
1062    async fn count(&self) -> anyhow::Result<u64> {
1063        let query = format!("SELECT COUNT(*) FROM {}", self.table_name);
1064        let count = crate::company_scope::fetch_one_scalar_scoped(
1065            &self.pool,
1066            sqlx::query_scalar::<_, i64>(&query),
1067        )
1068        .await? as u64;
1069        Ok(count)
1070    }
1071
1072    async fn exists(&self, id: &str) -> anyhow::Result<bool> {
1073        let query = format!("SELECT 1 FROM {} WHERE id = $1::uuid LIMIT 1", self.table_name);
1074        let result = crate::company_scope::fetch_optional_scalar_scoped(
1075            &self.pool,
1076            sqlx::query_scalar::<_, i32>(&query).bind(id),
1077        )
1078        .await?;
1079        Ok(result.is_some())
1080    }
1081
1082    async fn execute_query(&self, query: &str) -> anyhow::Result<u64> {
1083        let result = crate::company_scope::execute_scoped(
1084            &self.pool,
1085            sqlx::query(query),
1086        )
1087        .await?;
1088        Ok(result.rows_affected())
1089    }
1090}
1091
1092// ─── Aggregation ──────────────────────────────────────────────────────────────
1093
1094/// Which reduction to apply to a column.
1095#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1096pub enum AggregateFn {
1097    Sum,
1098    Avg,
1099    Min,
1100    Max,
1101}
1102
1103impl AggregateFn {
1104    fn sql(self) -> &'static str {
1105        match self {
1106            AggregateFn::Sum => "SUM",
1107            AggregateFn::Avg => "AVG",
1108            AggregateFn::Min => "MIN",
1109            AggregateFn::Max => "MAX",
1110        }
1111    }
1112
1113    fn label(self) -> &'static str {
1114        match self {
1115            AggregateFn::Sum => "sum",
1116            AggregateFn::Avg => "avg",
1117            AggregateFn::Min => "min",
1118            AggregateFn::Max => "max",
1119        }
1120    }
1121
1122    /// `SUM`/`AVG` on a text column is a type error, not a zero. `MIN`/`MAX`
1123    /// order any comparable type, so they carry no such restriction.
1124    fn requires_numeric(self) -> bool {
1125        matches!(self, AggregateFn::Sum | AggregateFn::Avg)
1126    }
1127}
1128
1129/// What to group by and what to reduce — the parsed form of the query string.
1130#[derive(Debug, Clone, Default)]
1131pub struct AggregateSpec {
1132    /// Column whose distinct values become groups. `None` asks for one total.
1133    pub group_by: Option<String>,
1134    /// `(function, column)` pairs, in the order the caller asked for them.
1135    pub reductions: Vec<(AggregateFn, String)>,
1136    /// Most groups to return before reporting the answer as truncated.
1137    pub group_limit: usize,
1138    /// Carry a label per group: the column on the group column's RELATED
1139    /// table (resolved through the entity's relation metadata) to show
1140    /// instead of a bare uuid key. `None` = keys stay as they are.
1141    pub label_field: Option<String>,
1142    /// The resolved relation behind the group column — `(target table, the
1143    /// BASE table's FK column, snake)`, filled by the generic layer from
1144    /// the entity's `relations()` metadata. Callers never set this.
1145    pub label_relation: Option<(String, String)>,
1146}
1147
1148/// The default ceiling on distinct groups.
1149///
1150/// A `group_by` on a uuid or a timestamp yields one group per row, which is a
1151/// table scan wearing a chart's clothes. Rather than refusing those columns —
1152/// a list that would be wrong for some schema sooner or later — the answer is
1153/// capped and the cap is *reported*, so a caller can tell a complete picture
1154/// from a partial one instead of quietly drawing the wrong one.
1155pub const DEFAULT_GROUP_LIMIT: usize = 200;
1156
1157/// One group's numbers. `key` is the group's value; `None` is a real answer —
1158/// the rows whose group column is null — and is distinct from "no rows".
1159#[derive(Debug, Clone, Serialize, Deserialize)]
1160pub struct AggregateGroup {
1161    pub key: Option<String>,
1162    /// The group's display label (the related row's label column), when the
1163    /// caller asked for one and the group column is a relation FK.
1164    pub label: Option<String>,
1165    pub count: u64,
1166    /// Reduction results keyed `"sum:amount"`, carried as strings.
1167    ///
1168    /// Postgres `numeric` holds more precision than an IEEE double, and money
1169    /// columns are exactly where that bites: a tenant large enough for the
1170    /// total to matter is a tenant large enough to round it. The string is the
1171    /// exact value Postgres computed; the caller decides how to parse it.
1172    /// `None` is SQL NULL — no rows contributed — which is not zero.
1173    pub values: HashMap<String, Option<String>>,
1174}
1175
1176/// Groups plus the overall total, computed together.
1177#[derive(Debug, Clone, Serialize, Deserialize)]
1178pub struct AggregateResult {
1179    pub groups: Vec<AggregateGroup>,
1180    pub total: AggregateGroup,
1181    /// True when more distinct groups exist than `group_limit` allowed.
1182    pub truncated: bool,
1183}
1184
1185/// A column name rejected by the allow-list, or a reduction that its type
1186/// cannot answer.
1187#[derive(Debug, Clone)]
1188pub struct AggregateFieldError(pub String);
1189
1190impl std::fmt::Display for AggregateFieldError {
1191    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1192        f.write_str(&self.0)
1193    }
1194}
1195
1196impl std::error::Error for AggregateFieldError {}
1197
1198/// True for the Postgres types `SUM`/`AVG` accept.
1199fn is_numeric_pg_type(pg_type: &str) -> bool {
1200    let t = pg_type.trim().to_ascii_lowercase();
1201    let t = t.split('(').next().unwrap_or(&t).trim();
1202    matches!(
1203        t,
1204        "numeric" | "decimal" | "money"
1205            | "smallint" | "int2" | "integer" | "int" | "int4" | "bigint" | "int8"
1206            | "real" | "float4" | "double precision" | "float8"
1207            | "smallserial" | "serial" | "bigserial"
1208    )
1209}
1210
1211/// Read a table's real columns and their types from the catalog.
1212///
1213/// `EntityRepoMeta::column_types()` looks like the natural allow-list and is
1214/// not one: it carries only the columns the filter parser must CAST — uuids and
1215/// enums — so every numeric column is absent from it, which is exactly the set
1216/// `sum` and `avg` exist for. The catalog is the only complete and current
1217/// answer, and it cannot drift from the table the query will actually run
1218/// against.
1219async fn catalog_columns(
1220    pool: &PgPool,
1221    qualified_table: &str,
1222) -> anyhow::Result<HashMap<String, String>> {
1223    let (schema, table) = match qualified_table.split_once('.') {
1224        Some((s, t)) => (s.to_string(), t.to_string()),
1225        None => ("public".to_string(), qualified_table.to_string()),
1226    };
1227    let q = sqlx::query_as::<Postgres, (String, String)>(
1228        "SELECT column_name, data_type FROM information_schema.columns
1229          WHERE table_schema = $1 AND table_name = $2",
1230    )
1231    .bind(schema)
1232    .bind(table);
1233    let rows = crate::company_scope::fetch_all_scoped(pool, q).await?;
1234    Ok(rows.into_iter().collect())
1235}
1236
1237/// Resolve a caller-supplied column name against the entity's real columns.
1238///
1239/// This is the whole defence for the aggregate path. Unlike a filter *value*,
1240/// which is bound as a parameter, a `group_by` or `sum` column is spliced into
1241/// the SQL as an identifier — binding cannot protect it. So the name is never
1242/// escaped or quoted into safety; it is *replaced* by the matching key already
1243/// present in the entity's declared column map, and a name with no match is
1244/// refused. Nothing a caller types can reach the query text.
1245fn resolve_column<'a>(
1246    name: &str,
1247    column_types: &'a HashMap<String, String>,
1248) -> Result<(&'a str, &'a str), AggregateFieldError> {
1249    column_types
1250        .get_key_value(name)
1251        .map(|(k, v)| (k.as_str(), v.as_str()))
1252        .ok_or_else(|| {
1253            AggregateFieldError(format!("unknown field `{name}` — not a column of this entity"))
1254        })
1255}
1256
1257impl<T: for<'a> FromRow<'a, PgRow> + Send + Unpin> PostgresRepository<T> {
1258    /// Group and reduce rows in one statement, under the same filters, the same
1259    /// soft-delete convention and the same tenancy fence as the list endpoint.
1260    ///
1261    /// Groups and the overall total come back from a single `GROUPING SETS`
1262    /// query, which is what lets a caller draw a chart and its headline from
1263    /// one reply, and what keeps the two numbers consistent — a separate total
1264    /// query could observe a different set of rows.
1265    pub async fn aggregate_with_filters(
1266        &self,
1267        spec: &AggregateSpec,
1268        filters: &HashMap<String, String>,
1269        column_types: &HashMap<String, String>,
1270        search_fields: &[&str],
1271    ) -> anyhow::Result<AggregateResult> {
1272        self.aggregate_with_filters_and_base(spec, None, filters, column_types, search_fields)
1273            .await
1274    }
1275
1276    /// [`Self::aggregate_with_filters`] under a repository-authored base condition — the same
1277    /// contract as [`Self::list_with_filters_and_base`], so an aggregate reduces exactly the
1278    /// rows the matching list returns.
1279    pub(crate) async fn aggregate_with_filters_and_base(
1280        &self,
1281        spec: &AggregateSpec,
1282        base_condition: Option<&str>,
1283        filters: &HashMap<String, String>,
1284        column_types: &HashMap<String, String>,
1285        search_fields: &[&str],
1286    ) -> anyhow::Result<AggregateResult> {
1287        let mut query_filter = self.parse_typed_filters(filters, column_types, None).await?;
1288        if let Some(cond) = base_condition {
1289            query_filter.add_base_condition(cond);
1290        }
1291        if !search_fields.is_empty() {
1292            query_filter.search_fields = search_fields.iter().map(|s| s.to_string()).collect();
1293        }
1294        // Grouping replaces row output entirely: paging and ordering describe a
1295        // page of rows, and there are none.
1296        query_filter.limit = None;
1297        query_filter.offset = None;
1298        let (where_clause, filter_params) = query_filter.build_where_clause();
1299
1300        // Every identifier below comes from the catalog, never from the caller.
1301        let columns = catalog_columns(&self.pool, &self.table_name).await?;
1302        let mut selects: Vec<String> = Vec::new();
1303        let mut value_keys: Vec<String> = Vec::new();
1304        for (func, field) in &spec.reductions {
1305            let (column, pg_type) = resolve_column(field, &columns)?;
1306            if func.requires_numeric() && !is_numeric_pg_type(pg_type) {
1307                return Err(AggregateFieldError(format!(
1308                    "cannot {} `{}`: its type is {} — {} needs a numeric column",
1309                    func.label(),
1310                    column,
1311                    pg_type,
1312                    func.label()
1313                ))
1314                .into());
1315            }
1316            let key = format!("{}:{}", func.label(), column);
1317            // Cast to text in SQL so the exact value Postgres computed is what
1318            // crosses the wire — see `AggregateGroup::values`.
1319            selects.push(format!("{}({})::text AS \"{}\"", func.sql(), column, key));
1320            value_keys.push(key);
1321        }
1322
1323        let group_limit = if spec.group_limit == 0 { DEFAULT_GROUP_LIMIT } else { spec.group_limit };
1324        let reductions = if selects.is_empty() { String::new() } else { format!(", {}", selects.join(", ")) };
1325
1326        // The group's label: when the caller names one (group_label) and the
1327        // group column is a relation FK of THIS entity, LEFT JOIN the
1328        // related table and carry its label column beside the key — the
1329        // aggregate's equivalent of `?include=`, which has no row to
1330        // hydrate otherwise.
1331        let mut label_select = String::new();
1332        let mut label_join = String::new();
1333        if let (Some(field), Some(label_field), Some((rel_table, base_fk))) = (
1334            &spec.group_by,
1335            &spec.label_field,
1336            &spec.label_relation,
1337        ) {
1338            let _ = field;
1339            let qualified = qualify_relation_table(&self.table_name, rel_table);
1340            let rel_columns = catalog_columns(&self.pool, &qualified).await?;
1341            let (label_col, _) = resolve_column(label_field, &rel_columns).map_err(|_| {
1342                AggregateFieldError(format!(
1343                    "cannot label groups by `{label_field}`: the related table `{qualified}` has no such column"
1344                ))
1345            })?;
1346            label_select = format!(", (label_rel.{label_col})::text AS __group_label");
1347            label_join = format!(
1348                " LEFT JOIN {qualified} AS label_rel ON label_rel.id IS NOT DISTINCT FROM {base_fk}"
1349            );
1350        }
1351
1352        let sql = match &spec.group_by {
1353            Some(field) => {
1354                let (column, _) = resolve_column(field, &columns)?;
1355                format!(
1356                    "SELECT GROUPING({column}) AS __is_total, ({column})::text AS __group_key{label_select}, \
1357                     COUNT(*) AS __count{reductions} \
1358                     FROM {table}{label_join}{where_clause} \
1359                     GROUP BY GROUPING SETS (({column}), ()) \
1360                     ORDER BY __is_total DESC, __count DESC \
1361                     LIMIT {limit}",
1362                    column = column,
1363                    label_select = label_select,
1364                    label_join = label_join,
1365                    reductions = reductions,
1366                    table = self.table_name,
1367                    where_clause = where_clause,
1368                    // One total row, the groups themselves, and one more to
1369                    // detect that a further group existed.
1370                    limit = group_limit + 2,
1371                )
1372            }
1373            None => format!(
1374                "SELECT 1 AS __is_total, NULL::text AS __group_key, COUNT(*) AS __count{reductions} \
1375                 FROM {table}{where_clause}",
1376                reductions = reductions,
1377                table = self.table_name,
1378                where_clause = where_clause,
1379            ),
1380        };
1381
1382        let mut builder = sqlx::query(&sql);
1383        for param in &filter_params {
1384            builder = builder.bind(param);
1385        }
1386        let rows = crate::company_scope::fetch_all_rows_scoped(&self.pool, builder).await?;
1387
1388        let read_group = |row: &PgRow| -> AggregateGroup {
1389            use sqlx::Row as _;
1390            let mut values = HashMap::with_capacity(value_keys.len());
1391            for key in &value_keys {
1392                values.insert(key.clone(), row.try_get::<Option<String>, _>(key.as_str()).ok().flatten());
1393            }
1394            AggregateGroup {
1395                key: row.try_get::<Option<String>, _>("__group_key").ok().flatten(),
1396                label: row.try_get::<Option<String>, _>("__group_label").ok().flatten(),
1397                count: row.try_get::<i64, _>("__count").unwrap_or(0).max(0) as u64,
1398                values,
1399            }
1400        };
1401
1402        use sqlx::Row as _;
1403        let mut total: Option<AggregateGroup> = None;
1404        let mut groups: Vec<AggregateGroup> = Vec::new();
1405        for row in &rows {
1406            let is_total = row.try_get::<i32, _>("__is_total").unwrap_or(0) == 1;
1407            if is_total {
1408                // Ordered first, so it survives the cap.
1409                total = Some(read_group(row));
1410            } else {
1411                groups.push(read_group(row));
1412            }
1413        }
1414
1415        let truncated = groups.len() > group_limit;
1416        groups.truncate(group_limit);
1417
1418        // No rows at all means no total row either: an empty result is a real
1419        // answer of zero, not a missing one.
1420        let total = total.unwrap_or_else(|| AggregateGroup {
1421            key: None,
1422            label: None,
1423            count: 0,
1424            values: value_keys.iter().map(|k| (k.clone(), None)).collect(),
1425        });
1426
1427        Ok(AggregateResult { groups, total, truncated })
1428    }
1429}
1430
1431#[cfg(test)]
1432mod aggregate_field_tests {
1433    use super::*;
1434
1435    fn columns() -> HashMap<String, String> {
1436        [
1437            ("status", "text"),
1438            ("total", "numeric"),
1439            ("qty", "integer"),
1440            ("notes", "text"),
1441        ]
1442        .iter()
1443        .map(|(k, v)| (k.to_string(), v.to_string()))
1444        .collect()
1445    }
1446
1447    #[test]
1448    fn resolves_only_declared_columns() {
1449        let cols = columns();
1450        assert_eq!(resolve_column("total", &cols).unwrap().0, "total");
1451        assert!(resolve_column("password_hash", &cols).is_err());
1452    }
1453
1454    /// The allow-list is the entire defence, because a group/sum column is
1455    /// spliced into SQL as an identifier and cannot be bound as a parameter.
1456    #[test]
1457    fn rejects_injection_attempts_rather_than_escaping_them() {
1458        let cols = columns();
1459        for probe in [
1460            "total) FROM selling.sales_orders; DROP TABLE users --",
1461            "status\"",
1462            "1=1",
1463            "total, (SELECT password FROM users)",
1464            "",
1465        ] {
1466            assert!(
1467                resolve_column(probe, &cols).is_err(),
1468                "`{probe}` must be refused, never escaped into the query"
1469            );
1470        }
1471    }
1472
1473    /// The returned name is the map's own key, not the caller's string, so no
1474    /// caller-controlled bytes can reach the SQL even on a match.
1475    #[test]
1476    fn returns_the_declared_key_not_the_callers_string() {
1477        let cols = columns();
1478        let (name, _) = resolve_column("total", &cols).unwrap();
1479        assert!(std::ptr::eq(name, cols.get_key_value("total").unwrap().0.as_str()));
1480    }
1481
1482    #[test]
1483    fn sum_and_avg_require_a_numeric_type() {
1484        assert!(AggregateFn::Sum.requires_numeric());
1485        assert!(AggregateFn::Avg.requires_numeric());
1486        // Ordering works on any comparable column, so these stay open.
1487        assert!(!AggregateFn::Min.requires_numeric());
1488        assert!(!AggregateFn::Max.requires_numeric());
1489    }
1490
1491    #[test]
1492    fn recognises_the_numeric_postgres_types() {
1493        for t in ["numeric", "NUMERIC(14,2)", "integer", "bigint", "double precision", "money"] {
1494            assert!(is_numeric_pg_type(t), "{t} should count as numeric");
1495        }
1496        for t in ["text", "uuid", "timestamptz", "boolean", "jsonb", "USER-DEFINED"] {
1497            assert!(!is_numeric_pg_type(t), "{t} must not accept a SUM");
1498        }
1499    }
1500}
1501
1502#[cfg(test)]
1503mod filter_cast_tests {
1504    use super::*;
1505
1506    #[test]
1507    fn typed_base_columns_get_their_own_cast() {
1508        for (t, want) in [
1509            ("boolean", "boolean"),
1510            ("integer", "integer"),
1511            ("bigint", "bigint"),
1512            ("smallint", "smallint"),
1513            ("numeric", "numeric"),
1514            ("double precision", "double precision"),
1515            ("uuid", "uuid"),
1516            ("date", "date"),
1517            ("time without time zone", "time"),
1518            ("timestamp with time zone", "timestamptz"),
1519            ("timestamp without time zone", "timestamp"),
1520        ] {
1521            assert_eq!(filter_cast_for(t, "b").as_deref(), Some(want), "{t}");
1522        }
1523    }
1524
1525    #[test]
1526    fn text_like_and_composite_columns_keep_the_text_bind() {
1527        for t in ["text", "character varying", "character", "jsonb", "json", "bytea", "text[]", "uuid[]"] {
1528            assert_eq!(filter_cast_for(t, "b"), None, "{t}");
1529        }
1530        // A domain or composite is not an enum, even though both are user-defined.
1531        assert_eq!(filter_cast_for("approvals.money_amount", "d"), None);
1532        assert_eq!(filter_cast_for("approvals.address", "c"), None);
1533    }
1534
1535    #[test]
1536    fn an_enum_casts_to_its_catalog_name() {
1537        assert_eq!(filter_cast_for("approval_status", "e").as_deref(), Some("approval_status"));
1538        assert_eq!(
1539            filter_cast_for("recruitment.stage_kind", "e").as_deref(),
1540            Some("recruitment.stage_kind")
1541        );
1542    }
1543
1544    #[test]
1545    fn a_generated_hint_wins_over_the_catalog_for_its_column() {
1546        let hints: HashMap<String, String> =
1547            [("id", "uuid"), ("status", "approval_status")].iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
1548        let catalog: HashMap<String, String> = [
1549            ("id", "uuid"),
1550            ("status", "approvals.approval_status"),
1551            ("folded", "boolean"),
1552            ("requested_by", "uuid"),
1553        ]
1554        .iter()
1555        .map(|(k, v)| (k.to_string(), v.to_string()))
1556        .collect();
1557        let merged = merge_filter_casts(&hints, catalog);
1558        // The bare enum hint gives way to the catalog's qualified name for the same type: a
1559        // tenant's search path need not hold the module's schema.
1560        assert_eq!(merged["status"], "approvals.approval_status");
1561        assert_eq!(merged["id"], "uuid");
1562        assert_eq!(merged["folded"], "boolean");
1563        assert_eq!(merged["requested_by"], "uuid");
1564        assert_eq!(merged.len(), 4);
1565    }
1566
1567    #[test]
1568    fn a_hint_for_another_type_still_decides_its_column() {
1569        let hints: HashMap<String, String> =
1570            [("amount", "numeric")].iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
1571        let catalog: HashMap<String, String> =
1572            [("amount", "real")].iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
1573        assert_eq!(merge_filter_casts(&hints, catalog)["amount"], "numeric");
1574    }
1575
1576    #[test]
1577    fn a_filter_on_a_bare_enum_hint_reads_the_catalog_to_qualify_it() {
1578        let hints: HashMap<String, String> = [("id", "uuid"), ("status", "task_status")]
1579            .iter()
1580            .map(|(k, v)| (k.to_string(), v.to_string()))
1581            .collect();
1582        let needs = |pairs: &[(&str, &str)]| {
1583            let f: HashMap<String, String> =
1584                pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
1585            parse_query_filter(&f, &hints, None).unwrap().needs_catalog_types()
1586        };
1587        assert!(needs(&[("status[in]", "done,skipped")]));
1588        assert!(needs(&[("status[eq]", "done")]));
1589        // A uuid hint resolves anywhere; nothing to read.
1590        assert!(!needs(&[("id[in]", "a,b")]));
1591    }
1592
1593    #[test]
1594    fn a_sort_on_an_enum_casts_to_its_schema_qualified_name() {
1595        assert_eq!(
1596            cast_suffix("USER-DEFINED", "task_status", "lifecycle").as_deref(),
1597            Some("lifecycle.task_status")
1598        );
1599        assert_eq!(cast_suffix("USER-DEFINED", "mood", "public").as_deref(), Some("mood"));
1600        assert_eq!(cast_suffix("uuid", "uuid", "pg_catalog").as_deref(), Some("uuid"));
1601    }
1602
1603    #[test]
1604    fn only_a_filter_with_an_uncast_comparison_reads_the_catalog() {
1605        let hints: HashMap<String, String> =
1606            [("id", "uuid")].iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
1607        let parse = |pairs: &[(&str, &str)]| {
1608            let f: HashMap<String, String> =
1609                pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
1610            parse_query_filter(&f, &hints, None).unwrap().has_uncast_value_conditions()
1611        };
1612        assert!(!parse(&[("id[in]", "a,b"), ("name[contain]", "x"), ("limit", "5")]));
1613        assert!(!parse(&[("deleted_by[isnull]", "1")]));
1614        assert!(parse(&[("folded[eq]", "false")]));
1615        assert!(parse(&[("folded", "false")]));
1616        assert!(parse(&[("scheduled_at[between]", "2026-10-01,2026-10-03")]));
1617        assert!(parse(&[("sequence[or]", "3")]));
1618    }
1619}
1620
1621#[cfg(test)]
1622mod keyset_predicate_tests {
1623    use super::and_keyset_predicate;
1624
1625    #[test]
1626    fn a_client_or_cannot_escape_the_keyset_window() {
1627        assert_eq!(
1628            and_keyset_predicate(" WHERE kind = $1 OR status = $2", "id > $3"),
1629            " WHERE (kind = $1 OR status = $2) AND (id > $3)"
1630        );
1631    }
1632
1633    #[test]
1634    fn no_filter_leaves_the_keyset_alone() {
1635        assert_eq!(and_keyset_predicate("", "id > $1"), " WHERE id > $1");
1636    }
1637}
1638
1639#[cfg(test)]
1640mod changed_columns_tests {
1641    use super::changed_columns;
1642
1643    #[derive(serde::Serialize)]
1644    struct Row {
1645        id: &'static str,
1646        notes: &'static str,
1647        status: &'static str,
1648        #[serde(skip_serializing_if = "Option::is_none")]
1649        label: Option<&'static str>,
1650    }
1651
1652    #[test]
1653    fn only_the_fields_that_differ_are_changed() {
1654        let before = Row {
1655            id: "1",
1656            notes: "a",
1657            status: "draft",
1658            label: None,
1659        };
1660        let after = Row {
1661            id: "2",
1662            notes: "b",
1663            status: "draft",
1664            label: Some("x"),
1665        };
1666        assert_eq!(
1667            changed_columns(&before, &after).unwrap(),
1668            vec!["label".to_string(), "notes".to_string()]
1669        );
1670        assert!(changed_columns(&before, &before).unwrap().is_empty());
1671    }
1672}