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