Skip to main content

prax_query/relations/
loader.rs

1//! Relation loading strategies and loaders.
2
3use std::collections::HashMap;
4
5use crate::filter::FilterValue;
6use crate::traits::QueryEngine;
7
8use super::include::IncludeSpec;
9use super::spec::RelationSpec;
10
11/// Strategy for loading relations.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13pub enum RelationLoadStrategy {
14    /// Load relations in separate queries (default, N+1 safe with batching).
15    #[default]
16    Separate,
17    /// Load relations using JOINs (single query, good for one-to-one/many-to-one).
18    Join,
19    /// Load relations lazily on access.
20    Lazy,
21}
22
23impl RelationLoadStrategy {
24    /// Check if this is a separate query strategy.
25    pub fn is_separate(&self) -> bool {
26        matches!(self, Self::Separate)
27    }
28
29    /// Check if this is a join strategy.
30    pub fn is_join(&self) -> bool {
31        matches!(self, Self::Join)
32    }
33
34    /// Check if this is lazy loading.
35    pub fn is_lazy(&self) -> bool {
36        matches!(self, Self::Lazy)
37    }
38}
39
40/// Relation loader for executing relation queries.
41pub struct RelationLoader<E: QueryEngine> {
42    engine: E,
43    strategy: RelationLoadStrategy,
44    batch_size: usize,
45}
46
47impl<E: QueryEngine> RelationLoader<E> {
48    /// Create a new relation loader.
49    pub fn new(engine: E) -> Self {
50        Self {
51            engine,
52            strategy: RelationLoadStrategy::Separate,
53            batch_size: 100,
54        }
55    }
56
57    /// Set the loading strategy.
58    pub fn with_strategy(mut self, strategy: RelationLoadStrategy) -> Self {
59        self.strategy = strategy;
60        self
61    }
62
63    /// Set the batch size for separate queries.
64    ///
65    /// Used by [`Self::build_one_to_many_queries`] to chunk parent IDs into
66    /// bounded `IN (...)` lists, one statement per chunk.
67    pub fn with_batch_size(mut self, size: usize) -> Self {
68        self.batch_size = size;
69        self
70    }
71
72    /// Get the engine.
73    pub fn engine(&self) -> &E {
74        &self.engine
75    }
76
77    /// Build a query for loading a one-to-many relation.
78    ///
79    /// Placeholders and the nested `Filter::to_sql` call are emitted through
80    /// the engine's dialect (`QueryEngine::dialect()`), so the statement
81    /// matches the connected backend.
82    pub fn build_one_to_many_query(
83        &self,
84        spec: &RelationSpec,
85        include: &IncludeSpec,
86        parent_ids: &[FilterValue],
87    ) -> (String, Vec<FilterValue>) {
88        let dialect = self.engine.dialect();
89
90        let mut sql = format!(
91            "SELECT * FROM {} WHERE {} IN (",
92            spec.related_table,
93            spec.references.first().unwrap_or(&"id".to_string())
94        );
95
96        let placeholders: Vec<_> = (1..=parent_ids.len())
97            .map(|i| dialect.placeholder(i))
98            .collect();
99        sql.push_str(&placeholders.join(", "));
100        sql.push(')');
101
102        let mut params = parent_ids.to_vec();
103
104        // Apply filter if present; ordering and pagination still apply below.
105        if let Some(ref filter) = include.filter {
106            let (filter_sql, filter_params) = filter.to_sql(parent_ids.len(), dialect);
107            sql.push_str(" AND ");
108            sql.push_str(&filter_sql);
109            params.extend(filter_params);
110        }
111
112        // Apply ordering
113        if let Some(ref order) = include.order_by {
114            sql.push_str(" ORDER BY ");
115            sql.push_str(&order.to_sql());
116        }
117
118        // Apply pagination (per-parent limits need subquery or window functions)
119        if let Some(ref pagination) = include.pagination {
120            let pagination_sql = pagination.to_sql();
121            if !pagination_sql.is_empty() {
122                sql.push(' ');
123                sql.push_str(&pagination_sql);
124            }
125        }
126
127        (sql, params)
128    }
129
130    /// Build one-to-many queries batched by [`Self::with_batch_size`].
131    ///
132    /// Chunks `parent_ids` into groups of at most `batch_size` and emits one
133    /// statement per chunk — ceil(N / batch_size) statements — keeping each
134    /// `IN (...)` list bounded. Callers using the separate-query strategy
135    /// should execute every statement and merge the results.
136    pub fn build_one_to_many_queries(
137        &self,
138        spec: &RelationSpec,
139        include: &IncludeSpec,
140        parent_ids: &[FilterValue],
141    ) -> Vec<(String, Vec<FilterValue>)> {
142        // Guard against a zero batch size, which would panic `slice::chunks`.
143        let batch_size = self.batch_size.max(1);
144        parent_ids
145            .chunks(batch_size)
146            .map(|chunk| self.build_one_to_many_query(spec, include, chunk))
147            .collect()
148    }
149
150    /// Build a query for loading a many-to-one relation.
151    pub fn build_many_to_one_query(
152        &self,
153        spec: &RelationSpec,
154        child_foreign_keys: &[FilterValue],
155    ) -> (String, Vec<FilterValue>) {
156        let default_pk = "id".to_string();
157        let pk = spec.references.first().unwrap_or(&default_pk);
158
159        let mut sql = format!("SELECT * FROM {} WHERE {} IN (", spec.related_table, pk);
160
161        let placeholders: Vec<_> = (1..=child_foreign_keys.len())
162            .map(|i| format!("${}", i))
163            .collect();
164        sql.push_str(&placeholders.join(", "));
165        sql.push(')');
166
167        (sql, child_foreign_keys.to_vec())
168    }
169
170    /// Build a query for loading a many-to-many relation.
171    pub fn build_many_to_many_query(
172        &self,
173        spec: &RelationSpec,
174        include: &IncludeSpec,
175        parent_ids: &[FilterValue],
176    ) -> (String, Vec<FilterValue>) {
177        let jt = spec.join_table.as_ref().unwrap_or_else(|| {
178            panic!(
179                "build_many_to_many_query: relation `{}` (table `{}`) has no join table; \
180                 a many-to-many RelationSpec must be constructed via \
181                 RelationSpec::many_to_many(...), which requires a JoinTableSpec",
182                spec.name, spec.related_table
183            )
184        });
185
186        let mut sql = format!(
187            "SELECT t.*, jt.{} as _parent_id FROM {} t \
188             INNER JOIN {} jt ON t.{} = jt.{} \
189             WHERE jt.{} IN (",
190            jt.source_column,
191            spec.related_table,
192            jt.table_name,
193            spec.references.first().unwrap_or(&"id".to_string()),
194            jt.target_column,
195            jt.source_column
196        );
197
198        let placeholders: Vec<_> = (1..=parent_ids.len()).map(|i| format!("${}", i)).collect();
199        sql.push_str(&placeholders.join(", "));
200        sql.push(')');
201
202        // Apply ordering
203        if let Some(ref order) = include.order_by {
204            sql.push_str(" ORDER BY ");
205            sql.push_str(&order.to_sql());
206        }
207
208        (sql, parent_ids.to_vec())
209    }
210}
211
212impl<E: QueryEngine + Clone> Clone for RelationLoader<E> {
213    fn clone(&self) -> Self {
214        Self {
215            engine: self.engine.clone(),
216            strategy: self.strategy,
217            batch_size: self.batch_size,
218        }
219    }
220}
221
222/// Result of loading relations, keyed by parent ID.
223pub type RelationLoadResult<T> = HashMap<String, Vec<T>>;
224
225/// Batch relation loading context.
226#[derive(Debug)]
227pub struct BatchLoadContext {
228    /// Parent IDs to load relations for.
229    pub parent_ids: Vec<FilterValue>,
230    /// Field to group results by.
231    pub group_by_field: String,
232}
233
234impl BatchLoadContext {
235    /// Create a new batch load context.
236    pub fn new(parent_ids: Vec<FilterValue>, group_by_field: impl Into<String>) -> Self {
237        Self {
238            parent_ids,
239            group_by_field: group_by_field.into(),
240        }
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use crate::error::{QueryError, QueryResult};
248    use crate::filter::Filter;
249    use crate::traits::{BoxFuture, Model};
250    use crate::types::OrderByField;
251
252    struct TestModel;
253
254    impl Model for TestModel {
255        const MODEL_NAME: &'static str = "TestModel";
256        const TABLE_NAME: &'static str = "test_models";
257        const PRIMARY_KEY: &'static [&'static str] = &["id"];
258        const COLUMNS: &'static [&'static str] = &["id", "name"];
259    }
260
261    #[derive(Clone)]
262    struct MockEngine;
263
264    impl QueryEngine for MockEngine {
265        fn dialect(&self) -> &dyn crate::dialect::SqlDialect {
266            &crate::dialect::Postgres
267        }
268
269        fn query_many<T: Model + Send + 'static>(
270            &self,
271            _sql: &str,
272            _params: Vec<FilterValue>,
273        ) -> BoxFuture<'_, QueryResult<Vec<T>>> {
274            Box::pin(async { Ok(Vec::new()) })
275        }
276
277        fn query_one<T: Model + Send + 'static>(
278            &self,
279            _sql: &str,
280            _params: Vec<FilterValue>,
281        ) -> BoxFuture<'_, QueryResult<T>> {
282            Box::pin(async { Err(QueryError::not_found("test")) })
283        }
284
285        fn query_optional<T: Model + Send + 'static>(
286            &self,
287            _sql: &str,
288            _params: Vec<FilterValue>,
289        ) -> BoxFuture<'_, QueryResult<Option<T>>> {
290            Box::pin(async { Ok(None) })
291        }
292
293        fn execute_insert<T: Model + Send + 'static>(
294            &self,
295            _sql: &str,
296            _params: Vec<FilterValue>,
297        ) -> BoxFuture<'_, QueryResult<T>> {
298            Box::pin(async { Err(QueryError::not_found("test")) })
299        }
300
301        fn execute_update<T: Model + Send + 'static>(
302            &self,
303            _sql: &str,
304            _params: Vec<FilterValue>,
305        ) -> BoxFuture<'_, QueryResult<Vec<T>>> {
306            Box::pin(async { Ok(Vec::new()) })
307        }
308
309        fn execute_delete(
310            &self,
311            _sql: &str,
312            _params: Vec<FilterValue>,
313        ) -> BoxFuture<'_, QueryResult<u64>> {
314            Box::pin(async { Ok(0) })
315        }
316
317        fn execute_raw(
318            &self,
319            _sql: &str,
320            _params: Vec<FilterValue>,
321        ) -> BoxFuture<'_, QueryResult<u64>> {
322            Box::pin(async { Ok(0) })
323        }
324
325        fn count(&self, _sql: &str, _params: Vec<FilterValue>) -> BoxFuture<'_, QueryResult<u64>> {
326            Box::pin(async { Ok(0) })
327        }
328    }
329
330    #[test]
331    fn test_relation_load_strategy() {
332        assert!(RelationLoadStrategy::Separate.is_separate());
333        assert!(RelationLoadStrategy::Join.is_join());
334        assert!(RelationLoadStrategy::Lazy.is_lazy());
335    }
336
337    #[test]
338    fn test_one_to_many_query() {
339        let loader = RelationLoader::new(MockEngine);
340        let spec = RelationSpec::one_to_many("posts", "Post", "posts").references(["author_id"]);
341        let include = IncludeSpec::new("posts");
342        let parent_ids = vec![FilterValue::Int(1), FilterValue::Int(2)];
343
344        let (sql, params) = loader.build_one_to_many_query(&spec, &include, &parent_ids);
345
346        assert!(sql.contains("SELECT * FROM posts"));
347        assert!(sql.contains("WHERE author_id IN"));
348        assert_eq!(params.len(), 2);
349    }
350
351    #[test]
352    fn test_one_to_many_query_with_filter_order_and_pagination() {
353        let loader = RelationLoader::new(MockEngine);
354        let spec = RelationSpec::one_to_many("posts", "Post", "posts").references(["author_id"]);
355        let include = IncludeSpec::new("posts")
356            .r#where(Filter::Equals("published".into(), FilterValue::Bool(true)))
357            .order_by(OrderByField::desc("created_at"))
358            .take(5);
359        let parent_ids = vec![FilterValue::Int(1), FilterValue::Int(2)];
360
361        let (sql, params) = loader.build_one_to_many_query(&spec, &include, &parent_ids);
362
363        assert!(sql.contains("WHERE author_id IN"));
364        assert!(sql.contains("AND"));
365        assert!(sql.contains("ORDER BY created_at DESC"));
366        assert!(sql.contains("LIMIT 5"));
367        // Ordering/pagination must come after the filter clause.
368        assert!(sql.find("AND").unwrap() < sql.find("ORDER BY").unwrap());
369        assert!(sql.find("ORDER BY").unwrap() < sql.find("LIMIT").unwrap());
370        // 2 parent IDs + 1 filter param.
371        assert_eq!(params.len(), 3);
372    }
373
374    #[test]
375    fn test_one_to_many_batched_queries() {
376        let loader = RelationLoader::new(MockEngine).with_batch_size(2);
377        let spec = RelationSpec::one_to_many("posts", "Post", "posts").references(["author_id"]);
378        let include = IncludeSpec::new("posts");
379        let parent_ids: Vec<_> = (1..=5).map(FilterValue::Int).collect();
380
381        let queries = loader.build_one_to_many_queries(&spec, &include, &parent_ids);
382
383        // ceil(5 / 2) = 3 statements.
384        assert_eq!(queries.len(), 3);
385        assert_eq!(queries[0].1.len(), 2);
386        assert_eq!(queries[1].1.len(), 2);
387        assert_eq!(queries[2].1.len(), 1);
388        // Each statement's IN list is bounded by the batch size.
389        assert!(queries[0].0.contains("$1, $2"));
390        assert!(queries[2].0.contains("$1)"));
391        assert!(!queries[2].0.contains("$2"));
392    }
393
394    #[test]
395    fn test_many_to_one_query() {
396        let loader = RelationLoader::new(MockEngine);
397        let spec = RelationSpec::many_to_one("author", "User", "users").references(["id"]);
398        let foreign_keys = vec![FilterValue::Int(1), FilterValue::Int(2)];
399
400        let (sql, params) = loader.build_many_to_one_query(&spec, &foreign_keys);
401
402        assert!(sql.contains("SELECT * FROM users"));
403        assert!(sql.contains("WHERE id IN"));
404        assert_eq!(params.len(), 2);
405    }
406}