Skip to main content

fraiseql_db/postgres/
where_generator.rs

1//! PostgreSQL WHERE clause SQL generation.
2//!
3//! `PostgresWhereGenerator` is a type alias for
4//! `GenericWhereGenerator<PostgresDialect>`.  All logic lives in
5//! [`crate::where_generator::GenericWhereGenerator`].
6
7use std::{
8    collections::{HashMap, HashSet},
9    sync::Arc,
10};
11
12use crate::{dialect::PostgresDialect, where_generator::GenericWhereGenerator};
13
14/// Cache of indexed columns for views.
15///
16/// This cache stores column names that follow the FraiseQL indexed column naming
17/// conventions:
18/// - Human-readable: `items__product__category__code` (double-underscore path)
19/// - Entity ID format: `f{entity_id}__{field_name}` (e.g., `f200100__code`)
20///
21/// When a WHERE clause references a nested path that has a corresponding indexed
22/// column, the generator uses the indexed column directly instead of JSONB
23/// extraction, enabling the database to use indexes for the query.
24///
25/// # Example
26///
27/// ```rust
28/// use fraiseql_db::postgres::IndexedColumnsCache;
29/// use std::collections::{HashMap, HashSet};
30///
31/// let mut cache = IndexedColumnsCache::new();
32///
33/// // Register indexed columns for a view
34/// let mut columns = HashSet::new();
35/// columns.insert("items__product__category__code".to_string());
36/// cache.insert("v_order_items".to_string(), columns);
37/// ```
38pub type IndexedColumnsCache = HashMap<String, HashSet<String>>;
39
40/// PostgreSQL WHERE clause generator.
41///
42/// Type alias for `GenericWhereGenerator<PostgresDialect>`.
43/// Refer to [`GenericWhereGenerator`] for full documentation.
44///
45/// # Example
46///
47/// ```rust
48/// use fraiseql_db::postgres::PostgresWhereGenerator;
49/// use fraiseql_db::{WhereClause, WhereOperator};
50/// use serde_json::json;
51///
52/// let generator = PostgresWhereGenerator::postgres_new();
53///
54/// let clause = WhereClause::Field {
55///     path: vec!["email".to_string()],
56///     operator: WhereOperator::Icontains,
57///     value: json!("example.com"),
58/// };
59///
60/// let (sql, params) = generator.generate(&clause).expect("Failed to generate SQL");
61/// // sql: "data->>'email' ILIKE '%' || $1 || '%'"
62/// ```
63pub type PostgresWhereGenerator = GenericWhereGenerator<PostgresDialect>;
64
65/// Constructor compatibility shim for `PostgresWhereGenerator`.
66///
67/// These `impl` blocks expose the same `new()` / `with_indexed_columns()`
68/// constructors that the old concrete struct had.
69impl PostgresWhereGenerator {
70    /// Create a new PostgreSQL WHERE generator.
71    #[must_use]
72    pub const fn postgres_new() -> Self {
73        Self::new(PostgresDialect)
74    }
75
76    /// Create a new PostgreSQL WHERE generator with indexed columns for a view.
77    ///
78    /// When indexed columns are provided, the generator uses them instead of
79    /// JSONB extraction for nested paths that have corresponding indexed columns.
80    ///
81    /// # Arguments
82    ///
83    /// * `indexed_columns` - Set of indexed column names for the current view
84    ///
85    /// # Example
86    ///
87    /// ```rust
88    /// use fraiseql_db::postgres::PostgresWhereGenerator;
89    /// use std::collections::HashSet;
90    /// use std::sync::Arc;
91    ///
92    /// let mut columns = HashSet::new();
93    /// columns.insert("items__product__category__code".to_string());
94    /// let generator = PostgresWhereGenerator::postgres_with_indexed_columns(Arc::new(columns));
95    /// ```
96    #[must_use]
97    pub fn postgres_with_indexed_columns(indexed_columns: Arc<HashSet<String>>) -> Self {
98        Self::new(PostgresDialect).with_indexed_columns(indexed_columns)
99    }
100}
101
102#[cfg(test)]
103#[allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
104mod tests {
105    use std::{collections::HashSet, sync::Arc};
106
107    use serde_json::json;
108
109    use super::*;
110    use crate::where_clause::{WhereClause, WhereOperator};
111
112    #[test]
113    fn test_simple_equality() {
114        let gen = PostgresWhereGenerator::new(PostgresDialect);
115        let clause = WhereClause::Field {
116            path:     vec!["email".to_string()],
117            operator: WhereOperator::Eq,
118            value:    json!("test@example.com"),
119        };
120
121        let (sql, params) = gen.generate(&clause).unwrap();
122        assert_eq!(sql, "data->>'email' = $1");
123        assert_eq!(params, vec![json!("test@example.com")]);
124    }
125
126    #[test]
127    fn test_icontains() {
128        let gen = PostgresWhereGenerator::new(PostgresDialect);
129        let clause = WhereClause::Field {
130            path:     vec!["name".to_string()],
131            operator: WhereOperator::Icontains,
132            value:    json!("alice"),
133        };
134
135        let (sql, params) = gen.generate(&clause).unwrap();
136        assert_eq!(sql, "data->>'name' ILIKE '%' || $1 || '%'");
137        assert_eq!(params, vec![json!("alice")]);
138    }
139
140    #[test]
141    fn test_and_clause() {
142        let gen = PostgresWhereGenerator::new(PostgresDialect);
143        let clause = WhereClause::And(vec![
144            WhereClause::Field {
145                path:     vec!["status".to_string()],
146                operator: WhereOperator::Eq,
147                value:    json!("active"),
148            },
149            WhereClause::Field {
150                path:     vec!["age".to_string()],
151                operator: WhereOperator::Gte,
152                value:    json!(18),
153            },
154        ]);
155
156        let (sql, params) = gen.generate(&clause).unwrap();
157        assert!(sql.contains("AND"), "Expected AND: {sql}");
158        assert_eq!(params.len(), 2);
159    }
160
161    #[test]
162    fn test_indexed_columns() {
163        let mut cols = HashSet::new();
164        cols.insert("items__product__category__code".to_string());
165        let gen = PostgresWhereGenerator::new(PostgresDialect).with_indexed_columns(Arc::new(cols));
166
167        let clause = WhereClause::Field {
168            path:     vec![
169                "items".to_string(),
170                "product".to_string(),
171                "category".to_string(),
172                "code".to_string(),
173            ],
174            operator: WhereOperator::Eq,
175            value:    json!("BOOK"),
176        };
177
178        let (sql, params) = gen.generate(&clause).unwrap();
179        assert!(
180            sql.contains("\"items__product__category__code\""),
181            "Expected indexed col, got: {sql}"
182        );
183        assert_eq!(params, vec![json!("BOOK")]);
184    }
185
186    #[test]
187    fn test_nested_path() {
188        let gen = PostgresWhereGenerator::new(PostgresDialect);
189        let clause = WhereClause::Field {
190            path:     vec!["address".to_string(), "city".to_string()],
191            operator: WhereOperator::Eq,
192            value:    json!("Paris"),
193        };
194
195        let (sql, _) = gen.generate(&clause).unwrap();
196        // Nested path: data->'address'->>'city'
197        assert!(sql.contains("data->"), "Expected JSONB path: {sql}");
198        assert!(sql.contains("address"), "Expected 'address' segment: {sql}");
199        assert!(sql.contains("city"), "Expected 'city' segment: {sql}");
200    }
201
202    #[test]
203    fn test_is_null() {
204        let gen = PostgresWhereGenerator::new(PostgresDialect);
205        let clause = WhereClause::Field {
206            path:     vec!["deleted_at".to_string()],
207            operator: WhereOperator::IsNull,
208            value:    json!(true),
209        };
210
211        let (sql, params) = gen.generate(&clause).unwrap();
212        assert_eq!(sql, "data->>'deleted_at' IS NULL");
213        assert!(params.is_empty());
214    }
215
216    #[test]
217    fn test_param_offset() {
218        let gen = PostgresWhereGenerator::new(PostgresDialect);
219        let clause = WhereClause::Field {
220            path:     vec!["email".to_string()],
221            operator: WhereOperator::Eq,
222            value:    json!("a@b.com"),
223        };
224
225        let (sql, _) = gen.generate_with_param_offset(&clause, 2).unwrap();
226        assert!(sql.contains("$3"), "Expected $3, got: {sql}");
227    }
228
229    #[test]
230    fn test_in_operator() {
231        let gen = PostgresWhereGenerator::new(PostgresDialect);
232        let clause = WhereClause::Field {
233            path:     vec!["status".to_string()],
234            operator: WhereOperator::In,
235            value:    json!(["active", "pending"]),
236        };
237
238        let (sql, params) = gen.generate(&clause).unwrap();
239        assert_eq!(sql, "data->>'status' IN ($1, $2)");
240        assert_eq!(params.len(), 2);
241    }
242
243    #[test]
244    fn test_vector_cosine_distance() {
245        let gen = PostgresWhereGenerator::new(PostgresDialect);
246        let clause = WhereClause::Field {
247            path:     vec!["embedding".to_string()],
248            operator: WhereOperator::CosineDistance,
249            value:    json!([0.1, 0.2, 0.3]),
250        };
251
252        let (sql, params) = gen.generate(&clause).unwrap();
253        assert!(sql.contains("<=>"), "Expected <=>: {sql}");
254        assert_eq!(params.len(), 1);
255    }
256
257    #[test]
258    fn test_ltree_ancestor_of() {
259        let gen = PostgresWhereGenerator::new(PostgresDialect);
260        let clause = WhereClause::Field {
261            path:     vec!["category_path".to_string()],
262            operator: WhereOperator::AncestorOf,
263            value:    json!("europe.france"),
264        };
265
266        let (sql, params) = gen.generate(&clause).unwrap();
267        assert!(sql.contains("@>"), "Expected @>: {sql}");
268        assert!(sql.contains("ltree"), "Expected ::ltree: {sql}");
269        assert_eq!(params.len(), 1);
270    }
271}