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