Skip to main content

reinhardt_db/orm/
loading.rs

1//! # Loading Strategies
2//!
3//! Implements SQLAlchemy-inspired loading strategies for relationships.
4//!
5//! This module provides different strategies for loading related objects:
6//! - Joined: Load via JOIN in a single query
7//! - Selectin: Load in separate SELECT IN query (most efficient for collections)
8//! - Subquery: Load via subquery
9//! - Lazy: Load on first access
10//! - Raise: Raise error if accessed when not loaded
11//! - NoLoad: Never load automatically
12//! - WriteOnly: Write-only collections (no read)
13//!
14//! This module is inspired by SQLAlchemy's loading.py
15//! Copyright 2005-2025 SQLAlchemy authors and contributors
16//! Licensed under MIT License. See THIRD-PARTY-NOTICES for details.
17
18use crate::orm::Model;
19use std::marker::PhantomData;
20
21/// Loading strategy for relationships
22/// Corresponds to SQLAlchemy's lazy parameter and loader options
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
24pub enum LoadingStrategy {
25	/// Load immediately with parent via JOIN
26	/// Most efficient for single objects, can cause cartesian product for collections
27	/// SQLAlchemy: lazy='joined' or joinedload()
28	Joined,
29
30	/// Load in a separate SELECT IN query after parent loads
31	/// Most efficient for collections, avoids cartesian product
32	/// SQLAlchemy: lazy='selectin' or selectinload()
33	Selectin,
34
35	/// Load via subquery that wraps the parent query
36	/// SQLAlchemy: lazy='subquery' or subqueryload()
37	Subquery,
38
39	/// Load on first access (traditional lazy loading)
40	/// SQLAlchemy: lazy='select' or lazy=True
41	Lazy,
42
43	/// Raise error if accessed when not loaded
44	/// Useful to catch N+1 query problems during development
45	/// SQLAlchemy: lazy='raise' or raiseload()
46	Raise,
47
48	/// Never load automatically, must be loaded explicitly
49	/// SQLAlchemy: lazy='noload' or noload()
50	NoLoad,
51
52	/// Write-only relationship, cannot be read
53	/// Useful for large collections that should only be modified
54	/// SQLAlchemy: lazy='write_only'
55	WriteOnly,
56
57	/// Return a dynamic query object instead of loading
58	/// Allows further filtering on the relationship
59	/// SQLAlchemy: lazy='dynamic'
60	Dynamic,
61}
62
63impl LoadingStrategy {
64	/// Check if this strategy requires immediate loading
65	///
66	pub fn is_eager(&self) -> bool {
67		matches!(
68			self,
69			LoadingStrategy::Joined | LoadingStrategy::Selectin | LoadingStrategy::Subquery
70		)
71	}
72	/// Check if this strategy loads on access
73	///
74	pub fn is_lazy(&self) -> bool {
75		matches!(self, LoadingStrategy::Lazy)
76	}
77	/// Check if this strategy prevents loading
78	///
79	pub fn prevents_load(&self) -> bool {
80		matches!(
81			self,
82			LoadingStrategy::Raise | LoadingStrategy::NoLoad | LoadingStrategy::WriteOnly
83		)
84	}
85	/// Get SQL hint for query planner
86	///
87	pub fn sql_hint(&self) -> Option<&'static str> {
88		match self {
89			LoadingStrategy::Joined => Some("/* +JOINEDLOAD */"),
90			LoadingStrategy::Selectin => Some("/* +SELECTINLOAD */"),
91			LoadingStrategy::Subquery => Some("/* +SUBQUERYLOAD */"),
92			_ => None,
93		}
94	}
95}
96
97/// Load option for a specific relationship path
98/// Corresponds to SQLAlchemy's Load object
99#[derive(Debug, Clone)]
100pub struct LoadOption {
101	/// Relationship path (e.g., "author.posts.comments")
102	path: String,
103	/// Loading strategy to use
104	strategy: LoadingStrategy,
105}
106
107impl LoadOption {
108	/// Create a new load option for a path
109	///
110	/// # Examples
111	///
112	/// ```
113	/// use reinhardt_db::orm::loading::{LoadOption, LoadingStrategy};
114	///
115	/// let option = LoadOption::new("posts", LoadingStrategy::Joined);
116	/// assert_eq!(option.path(), "posts");
117	/// ```
118	pub fn new(path: impl Into<String>, strategy: LoadingStrategy) -> Self {
119		Self {
120			path: path.into(),
121			strategy,
122		}
123	}
124	/// Get the relationship path
125	///
126	pub fn path(&self) -> &str {
127		&self.path
128	}
129	/// Get the loading strategy
130	///
131	pub fn strategy(&self) -> LoadingStrategy {
132		self.strategy
133	}
134	/// Parse path into components
135	///
136	pub fn path_components(&self) -> Vec<&str> {
137		self.path.split('.').collect()
138	}
139}
140
141/// Builder for load options
142/// Provides a fluent API similar to SQLAlchemy's Load
143pub struct LoadOptionBuilder<T: Model> {
144	options: Vec<LoadOption>,
145	_phantom: PhantomData<T>,
146}
147
148impl<T: Model> LoadOptionBuilder<T> {
149	/// Create a new builder
150	///
151	/// # Examples
152	///
153	/// ```
154	/// use reinhardt_db::orm::{Model, loading::LoadOptionBuilder};
155	/// use serde::{Serialize, Deserialize};
156	///
157	/// #[derive(Debug, Clone, Serialize, Deserialize)]
158	/// struct User {
159	///     id: Option<i32>,
160	/// }
161	///
162	/// # #[derive(Clone)]
163	/// # struct UserFields;
164	/// # impl reinhardt_db::orm::model::FieldSelector for UserFields {
165	/// #     fn with_alias(self, _alias: &str) -> Self { self }
166	/// # }
167	/// impl Model for User {
168	///     type PrimaryKey = i32;
169	///     type Fields = UserFields;
170	///     type Objects = reinhardt_db::orm::Manager<Self>;
171	///     fn table_name() -> &'static str {
172	///         "users"
173	///     }
174	///     fn new_fields() -> Self::Fields {
175	///         UserFields
176	///     }
177	///     fn primary_key(&self) -> Option<Self::PrimaryKey> {
178	///         self.id
179	///     }
180	///     fn set_primary_key(&mut self, value: Self::PrimaryKey) {
181	///         self.id = Some(value);
182	///     }
183	/// }
184	///
185	/// let builder: LoadOptionBuilder<User> = LoadOptionBuilder::new();
186	/// // Builder is ready to configure loading options
187	/// ```
188	pub fn new() -> Self {
189		Self {
190			options: Vec::new(),
191			_phantom: PhantomData,
192		}
193	}
194	/// Load relationship via JOIN
195	/// SQLAlchemy: query.options(joinedload(User.addresses))
196	///
197	pub fn joinedload(mut self, path: impl Into<String>) -> Self {
198		self.options
199			.push(LoadOption::new(path, LoadingStrategy::Joined));
200		self
201	}
202	/// Load relationship via SELECT IN
203	/// SQLAlchemy: query.options(selectinload(User.addresses))
204	///
205	pub fn selectinload(mut self, path: impl Into<String>) -> Self {
206		self.options
207			.push(LoadOption::new(path, LoadingStrategy::Selectin));
208		self
209	}
210	/// Load relationship via subquery
211	/// SQLAlchemy: query.options(subqueryload(User.addresses))
212	///
213	pub fn subqueryload(mut self, path: impl Into<String>) -> Self {
214		self.options
215			.push(LoadOption::new(path, LoadingStrategy::Subquery));
216		self
217	}
218	/// Load relationship lazily on access
219	/// SQLAlchemy: query.options(lazyload(User.addresses))
220	///
221	pub fn lazyload(mut self, path: impl Into<String>) -> Self {
222		self.options
223			.push(LoadOption::new(path, LoadingStrategy::Lazy));
224		self
225	}
226	/// Raise error if relationship is accessed
227	/// SQLAlchemy: query.options(raiseload(User.addresses))
228	///
229	pub fn raiseload(mut self, path: impl Into<String>) -> Self {
230		self.options
231			.push(LoadOption::new(path, LoadingStrategy::Raise));
232		self
233	}
234	/// Never load relationship
235	/// SQLAlchemy: query.options(noload(User.addresses))
236	///
237	pub fn noload(mut self, path: impl Into<String>) -> Self {
238		self.options
239			.push(LoadOption::new(path, LoadingStrategy::NoLoad));
240		self
241	}
242	/// Build the list of load options
243	///
244	pub fn build(self) -> Vec<LoadOption> {
245		self.options
246	}
247}
248
249impl<T: Model> Default for LoadOptionBuilder<T> {
250	fn default() -> Self {
251		Self::new()
252	}
253}
254
255/// Helper functions to create load options
256/// These mirror SQLAlchemy's top-level functions
257/// Helper functions to create load options
258/// These mirror SQLAlchemy's top-level functions
259/// Create a joinedload option
260///
261pub fn joinedload(path: impl Into<String>) -> LoadOption {
262	LoadOption::new(path, LoadingStrategy::Joined)
263}
264/// Create a selectinload option
265///
266pub fn selectinload(path: impl Into<String>) -> LoadOption {
267	LoadOption::new(path, LoadingStrategy::Selectin)
268}
269/// Create a subqueryload option
270///
271pub fn subqueryload(path: impl Into<String>) -> LoadOption {
272	LoadOption::new(path, LoadingStrategy::Subquery)
273}
274/// Create a lazyload option
275///
276pub fn lazyload(path: impl Into<String>) -> LoadOption {
277	LoadOption::new(path, LoadingStrategy::Lazy)
278}
279/// Create a raiseload option
280///
281pub fn raiseload(path: impl Into<String>) -> LoadOption {
282	LoadOption::new(path, LoadingStrategy::Raise)
283}
284/// Create a noload option
285///
286pub fn noload(path: impl Into<String>) -> LoadOption {
287	LoadOption::new(path, LoadingStrategy::NoLoad)
288}
289
290/// Load execution context
291/// Tracks which relationships have been loaded and how
292#[derive(Debug, Default)]
293pub struct LoadContext {
294	/// Paths that have been loaded
295	loaded_paths: Vec<String>,
296	/// Strategies used for each path
297	strategies: Vec<(String, LoadingStrategy)>,
298}
299
300impl LoadContext {
301	/// Create a new load context
302	///
303	/// # Examples
304	///
305	/// ```
306	/// use reinhardt_db::orm::loading::LoadContext;
307	///
308	/// let context = LoadContext::new();
309	/// ```
310	pub fn new() -> Self {
311		Self::default()
312	}
313	/// Mark a path as loaded with a strategy
314	///
315	pub fn mark_loaded(&mut self, path: String, strategy: LoadingStrategy) {
316		self.loaded_paths.push(path.clone());
317		self.strategies.push((path, strategy));
318	}
319	/// Check if a path has been loaded
320	///
321	pub fn is_loaded(&self, path: &str) -> bool {
322		self.loaded_paths.contains(&path.to_string())
323	}
324	/// Get the strategy used for a path
325	///
326	pub fn strategy_for(&self, path: &str) -> Option<LoadingStrategy> {
327		self.strategies
328			.iter()
329			.find(|(p, _)| p == path)
330			.map(|(_, s)| *s)
331	}
332	/// Get all loaded paths
333	///
334	pub fn loaded_paths(&self) -> &[String] {
335		&self.loaded_paths
336	}
337}
338
339#[cfg(test)]
340mod tests {
341	use super::*;
342	use crate::orm::Manager;
343	use reinhardt_core::validators::TableName;
344	use serde::{Deserialize, Serialize};
345
346	#[derive(Debug, Clone, Serialize, Deserialize)]
347	struct User {
348		id: Option<i64>,
349	}
350
351	#[derive(Clone)]
352	struct UserFields;
353	impl crate::orm::model::FieldSelector for UserFields {
354		fn with_alias(self, _alias: &str) -> Self {
355			self
356		}
357	}
358
359	const USER_TABLE: TableName = TableName::new_const("users");
360
361	impl Model for User {
362		type PrimaryKey = i64;
363		type Fields = UserFields;
364		type Objects = Manager<Self>;
365
366		fn table_name() -> &'static str {
367			USER_TABLE.as_str()
368		}
369
370		fn new_fields() -> Self::Fields {
371			UserFields
372		}
373
374		fn primary_key(&self) -> Option<Self::PrimaryKey> {
375			self.id
376		}
377
378		fn set_primary_key(&mut self, value: Self::PrimaryKey) {
379			self.id = Some(value);
380		}
381	}
382
383	#[test]
384	fn test_loading_strategy_properties_unit() {
385		assert!(LoadingStrategy::Joined.is_eager());
386		assert!(LoadingStrategy::Selectin.is_eager());
387		assert!(LoadingStrategy::Subquery.is_eager());
388		assert!(LoadingStrategy::Lazy.is_lazy());
389		assert!(LoadingStrategy::Raise.prevents_load());
390		assert!(LoadingStrategy::NoLoad.prevents_load());
391	}
392
393	#[test]
394	fn test_load_option_creation() {
395		let opt = LoadOption::new("author.posts", LoadingStrategy::Joined);
396		assert_eq!(opt.path(), "author.posts");
397		assert_eq!(opt.strategy(), LoadingStrategy::Joined);
398		assert_eq!(opt.path_components(), vec!["author", "posts"]);
399	}
400
401	#[test]
402	fn test_load_option_builder() {
403		let options = LoadOptionBuilder::<User>::new()
404			.joinedload("posts")
405			.selectinload("comments")
406			.raiseload("profile")
407			.build();
408
409		assert_eq!(options.len(), 3);
410		assert_eq!(options[0].path(), "posts");
411		assert_eq!(options[0].strategy(), LoadingStrategy::Joined);
412		assert_eq!(options[1].path(), "comments");
413		assert_eq!(options[1].strategy(), LoadingStrategy::Selectin);
414		assert_eq!(options[2].path(), "profile");
415		assert_eq!(options[2].strategy(), LoadingStrategy::Raise);
416	}
417
418	#[test]
419	fn test_load_context() {
420		let mut ctx = LoadContext::new();
421		ctx.mark_loaded("posts".to_string(), LoadingStrategy::Joined);
422		ctx.mark_loaded("comments".to_string(), LoadingStrategy::Selectin);
423
424		assert!(ctx.is_loaded("posts"));
425		assert!(ctx.is_loaded("comments"));
426		assert!(!ctx.is_loaded("profile"));
427		assert_eq!(ctx.strategy_for("posts"), Some(LoadingStrategy::Joined));
428		assert_eq!(
429			ctx.strategy_for("comments"),
430			Some(LoadingStrategy::Selectin)
431		);
432	}
433
434	#[test]
435	fn test_helper_functions() {
436		let joined = joinedload("posts");
437		assert_eq!(joined.strategy(), LoadingStrategy::Joined);
438
439		let selectin = selectinload("comments");
440		assert_eq!(selectin.strategy(), LoadingStrategy::Selectin);
441
442		let raise = raiseload("profile");
443		assert_eq!(raise.strategy(), LoadingStrategy::Raise);
444	}
445
446	#[test]
447	fn test_loading_sql_hints() {
448		assert!(LoadingStrategy::Joined.sql_hint().is_some());
449		assert!(LoadingStrategy::Selectin.sql_hint().is_some());
450		assert!(LoadingStrategy::Subquery.sql_hint().is_some());
451		assert!(LoadingStrategy::Lazy.sql_hint().is_none());
452	}
453}