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