Skip to main content

rust_ef/
lazy.rs

1//! Lazy loading infrastructure for navigation properties.
2//!
3//! Provides the `LazyContext` trait and `LazyContextImpl` struct that carry
4//! the information needed to defer-load a navigation property on first
5//! access. Containers (`BelongsTo<T>` / `HasMany<T>` / `HasOne<T>`) hold an
6//! `Option<Arc<dyn LazyContext>>` and expose an async `load()` method.
7//!
8//! ## Recursion guard
9//!
10//! A `tokio::task_local!` depth counter prevents infinite lazy-loading
11//! chains (e.g. `Blog → Posts → Blog → ...`). The maximum depth is
12//! [`MAX_LAZY_DEPTH`]; exceeding it yields
13//! `EFError::other("lazy loading recursion limit exceeded")`.
14
15use crate::entity::IEntityType;
16use crate::error::{EFError, EFResult};
17use crate::metadata::{NavigationKind, NavigationMeta};
18use crate::provider::{DbValue, IDatabaseProvider, ISqlGenerator};
19use crate::query::CompiledFilter;
20use std::collections::HashMap;
21use std::sync::Arc;
22
23/// Maximum nesting depth for lazy-loading chains.
24///
25/// A depth of 16 allows chains like
26/// `Blog → Posts → Comments → Author → ...` (up to 16 levels) before
27/// bailing out. Real-world graphs rarely exceed 3–4 levels; the limit
28/// exists primarily to catch cyclic navigation graphs.
29pub const MAX_LAZY_DEPTH: usize = 16;
30
31// ---------------------------------------------------------------------------
32// LazyContext trait
33// ---------------------------------------------------------------------------
34
35/// Context attached to a navigation container enabling deferred loading.
36///
37/// Stored as `Arc<dyn LazyContext>` inside `BelongsTo<T>` / `HasMany<T>` /
38/// `HasOne<T>`. The context carries everything `load()` needs to build and
39/// execute a single-entity navigation query:
40///
41/// - The database provider (for connection + SQL generation)
42/// - The owning entity's primary-key values and full property snapshot
43/// - The `NavigationMeta` describing which navigation to load
44/// - Optional global query filters (e.g. tenant isolation)
45/// - The current recursion depth (for cycle detection)
46///
47/// Implemented by [`LazyContextImpl`]; users can provide custom
48/// implementations for advanced scenarios (e.g. custom caching).
49pub trait LazyContext: Send + Sync {
50    /// The database provider used to execute the lazy-load query.
51    fn provider(&self) -> &Arc<dyn IDatabaseProvider>;
52
53    /// The owning entity's full property snapshot (field_name → value).
54    ///
55    /// Used to extract foreign-key values for `BelongsTo` navigations.
56    fn owner_snapshot(&self) -> &HashMap<String, DbValue>;
57
58    /// The owning entity's primary-key values (field_name → value).
59    ///
60    /// Used to extract principal-key values for `HasMany` / `HasOne`
61    /// navigations.
62    fn owner_key_values(&self) -> &HashMap<String, DbValue>;
63
64    /// Metadata describing the navigation to lazy-load.
65    fn navigation(&self) -> &NavigationMeta;
66
67    /// Optional global query filters keyed by table name.
68    ///
69    /// Applied to the related table's query so lazy-loaded data respects
70    /// the same scoping (e.g. tenant isolation) as top-level queries.
71    fn filter_map(&self) -> Option<&HashMap<String, CompiledFilter>>;
72
73    /// Current recursion depth (0 = top-level lazy load).
74    ///
75    /// Incremented each time `load()` materializes child entities and
76    /// attaches new lazy contexts to them.
77    fn depth(&self) -> usize;
78}
79
80/// Concrete implementation of [`LazyContext`].
81///
82/// Constructed by the `#[derive(EntityType)]` macro's `ILazyInit`
83/// implementation for each navigation field on an entity.
84pub struct LazyContextImpl {
85    provider: Arc<dyn IDatabaseProvider>,
86    owner_snapshot: HashMap<String, DbValue>,
87    owner_key_values: HashMap<String, DbValue>,
88    navigation: NavigationMeta,
89    filter_map: Option<Arc<HashMap<String, CompiledFilter>>>,
90    depth: usize,
91}
92
93impl LazyContextImpl {
94    /// Creates a new lazy context for a single navigation field.
95    pub fn new(
96        provider: Arc<dyn IDatabaseProvider>,
97        owner_snapshot: HashMap<String, DbValue>,
98        owner_key_values: HashMap<String, DbValue>,
99        navigation: NavigationMeta,
100        filter_map: Option<Arc<HashMap<String, CompiledFilter>>>,
101        depth: usize,
102    ) -> Self {
103        Self {
104            provider,
105            owner_snapshot,
106            owner_key_values,
107            navigation,
108            filter_map,
109            depth,
110        }
111    }
112}
113
114impl LazyContext for LazyContextImpl {
115    fn provider(&self) -> &Arc<dyn IDatabaseProvider> {
116        &self.provider
117    }
118    fn owner_snapshot(&self) -> &HashMap<String, DbValue> {
119        &self.owner_snapshot
120    }
121    fn owner_key_values(&self) -> &HashMap<String, DbValue> {
122        &self.owner_key_values
123    }
124    fn navigation(&self) -> &NavigationMeta {
125        &self.navigation
126    }
127    fn filter_map(&self) -> Option<&HashMap<String, CompiledFilter>> {
128        self.filter_map.as_deref()
129    }
130    fn depth(&self) -> usize {
131        self.depth
132    }
133}
134
135// ---------------------------------------------------------------------------
136// Query building for single-entity lazy loading
137// ---------------------------------------------------------------------------
138
139/// Builds the SQL and parameters for a single-entity lazy-load query.
140///
141/// Returns `Ok(None)` when the navigation metadata is incomplete (missing
142/// `related_table` / `fk_column` / `referenced_key_column`); callers should
143/// treat this as "nothing to load" and mark the container as loaded-but-empty.
144///
145/// For `ManyToMany` navigations, returns `Ok(None)` — M2M lazy loading
146/// requires a two-phase query handled separately by
147/// [`build_m2m_lazy_queries`].
148fn build_lazy_query(
149    nav: &NavigationMeta,
150    owner_snapshot: &HashMap<String, DbValue>,
151    owner_key_values: &HashMap<String, DbValue>,
152    gen: &dyn ISqlGenerator,
153    filter_map: Option<&HashMap<String, CompiledFilter>>,
154) -> EFResult<Option<(String, Vec<DbValue>)>> {
155    let Some(related_table) = nav.related_table.as_deref() else {
156        return Ok(None);
157    };
158    let Some(fk_column) = nav.fk_column.as_deref() else {
159        return Ok(None);
160    };
161    let Some(ref_column) = nav.referenced_key_column.as_deref() else {
162        return Ok(None);
163    };
164
165    match nav.kind {
166        NavigationKind::HasMany | NavigationKind::HasOne => {
167            // The FK is on the related table; we need the owner's PK value
168            // (matched by ref_column) to query WHERE related.fk = owner.pk.
169            let Some(owner_pk) = owner_key_values.get(ref_column) else {
170                return Ok(None);
171            };
172            let placeholder = gen.parameter_placeholder(1);
173            let mut sql = format!(
174                "SELECT * FROM {} WHERE {} = {}",
175                related_table, fk_column, placeholder
176            );
177            let mut params = vec![owner_pk.clone()];
178            apply_filter(&mut sql, &mut params, related_table, filter_map, gen);
179            if nav.kind == NavigationKind::HasOne {
180                sql.push_str(" LIMIT 1");
181            }
182            Ok(Some((sql, params)))
183        }
184        NavigationKind::BelongsTo => {
185            // The FK is on THIS entity; we need the owner's FK value
186            // (matched by fk_column) to query WHERE related.pk = owner.fk.
187            let Some(owner_fk) = owner_snapshot.get(fk_column) else {
188                return Ok(None);
189            };
190            if matches!(owner_fk, DbValue::Null) {
191                // FK is NULL — no parent to load.
192                return Ok(None);
193            }
194            let placeholder = gen.parameter_placeholder(1);
195            let mut sql = format!(
196                "SELECT * FROM {} WHERE {} = {}",
197                related_table, ref_column, placeholder
198            );
199            let mut params = vec![owner_fk.clone()];
200            apply_filter(&mut sql, &mut params, related_table, filter_map, gen);
201            Ok(Some((sql, params)))
202        }
203        NavigationKind::ManyToMany => {
204            // M2M is handled by build_m2m_lazy_queries (two-phase).
205            Ok(None)
206        }
207    }
208}
209
210/// Builds the two SQL queries for a many-to-many lazy load.
211///
212/// Phase 1: query the join table for related IDs.
213/// Phase 2: query the related table for entities matching those IDs.
214///
215/// Returns `Ok(None)` if M2M metadata is incomplete.
216fn build_m2m_lazy_queries(
217    nav: &NavigationMeta,
218    owner_key_values: &HashMap<String, DbValue>,
219    ref_column: &str,
220    gen: &dyn ISqlGenerator,
221    filter_map: Option<&HashMap<String, CompiledFilter>>,
222) -> EFResult<Option<(String, Vec<DbValue>, String)>> {
223    let Some(join_table) = nav.through_table.as_deref() else {
224        return Ok(None);
225    };
226    let Some(parent_fk) = nav.through_parent_fk.as_deref() else {
227        return Ok(None);
228    };
229    let Some(related_fk) = nav.through_related_fk.as_deref() else {
230        return Ok(None);
231    };
232    let Some(related_table) = nav.related_table.as_deref() else {
233        return Ok(None);
234    };
235    let Some(owner_pk) = owner_key_values.get(ref_column) else {
236        return Ok(None);
237    };
238
239    // Phase 1: SELECT related_fk FROM join_table WHERE parent_fk = ?
240    let placeholder = gen.parameter_placeholder(1);
241    let join_sql = format!(
242        "SELECT {} FROM {} WHERE {} = {}",
243        related_fk, join_table, parent_fk, placeholder
244    );
245    let join_params = vec![owner_pk.clone()];
246
247    // Phase 2 SQL is built dynamically after phase 1 returns the IDs,
248    // because the IN-list size is unknown upfront. We return a template
249    // that the caller fills in.
250    let _ = filter_map; // filter applied in phase 2 (on related_table)
251    Ok(Some((join_sql, join_params, related_table.to_string())))
252}
253
254/// Appends a global query filter (e.g. `tenant_id = ?`) to the SQL.
255fn apply_filter(
256    sql: &mut String,
257    params: &mut Vec<DbValue>,
258    related_table: &str,
259    filter_map: Option<&HashMap<String, CompiledFilter>>,
260    gen: &dyn ISqlGenerator,
261) {
262    use crate::query::compile_bool_expr;
263
264    if let Some(filter) = filter_map.and_then(|m| m.get(related_table)) {
265        let mut idx = params.len() + 1;
266        let filter_sql = compile_bool_expr(&filter.expr, gen, &mut idx);
267        params.extend(filter.params.iter().cloned());
268        *sql = format!("{} AND ({})", sql, filter_sql);
269    }
270}
271
272// ---------------------------------------------------------------------------
273// Public helpers used by container load() methods
274// ---------------------------------------------------------------------------
275
276/// Executes a lazy-load query for a scalar navigation (BelongsTo / HasOne)
277/// and returns the materialized entity, or `None` if no matching row.
278///
279/// This function is called by `BelongsTo<T>::load()` and
280/// `HasOne<T>::load()`.
281pub async fn load_scalar_lazy<T>(ctx: &dyn LazyContext) -> EFResult<Option<T>>
282where
283    T: IEntityType + crate::entity::IFromRow,
284{
285    let nav = ctx.navigation();
286    if nav.kind == NavigationKind::ManyToMany {
287        return Err(EFError::other(
288            "load_scalar_lazy called for ManyToMany navigation",
289        ));
290    }
291
292    let gen = ctx.provider().sql_generator();
293    let Some((sql, params)) = build_lazy_query(
294        nav,
295        ctx.owner_snapshot(),
296        ctx.owner_key_values(),
297        gen,
298        ctx.filter_map(),
299    )?
300    else {
301        return Ok(None);
302    };
303
304    let provider = ctx.provider();
305    let mut conn = provider.get_connection().await?;
306    let rows = conn.query(&sql, &params).await?;
307    drop(sql);
308    drop(params);
309
310    if let Some(row) = rows.into_iter().next() {
311        let entity = T::from_row(&row)?;
312        Ok(Some(entity))
313    } else {
314        Ok(None)
315    }
316}
317
318/// Executes a lazy-load query for a collection navigation (HasMany) and
319/// returns the materialized entities.
320///
321/// This function is called by `HasMany<T>::load()`.
322pub async fn load_collection_lazy<T>(ctx: &dyn LazyContext) -> EFResult<Vec<T>>
323where
324    T: IEntityType + crate::entity::IFromRow,
325{
326    let nav = ctx.navigation();
327    if nav.kind == NavigationKind::ManyToMany {
328        return load_m2m_collection_lazy::<T>(ctx).await;
329    }
330
331    let gen = ctx.provider().sql_generator();
332    let Some((sql, params)) = build_lazy_query(
333        nav,
334        ctx.owner_snapshot(),
335        ctx.owner_key_values(),
336        gen,
337        ctx.filter_map(),
338    )?
339    else {
340        return Ok(Vec::new());
341    };
342
343    let provider = ctx.provider();
344    let mut conn = provider.get_connection().await?;
345    let rows = conn.query(&sql, &params).await?;
346
347    let mut entities = Vec::with_capacity(rows.len());
348    for row in rows {
349        entities.push(T::from_row(&row)?);
350    }
351    Ok(entities)
352}
353
354/// Executes a two-phase lazy-load for many-to-many navigations.
355async fn load_m2m_collection_lazy<T>(ctx: &dyn LazyContext) -> EFResult<Vec<T>>
356where
357    T: IEntityType + crate::entity::IFromRow,
358{
359    let nav = ctx.navigation();
360    let provider = ctx.provider();
361    let gen = provider.sql_generator();
362
363    // Determine the owner's PK column (used as ref_column).
364    let ref_column = nav.referenced_key_column.as_deref().unwrap_or("id");
365
366    let Some((join_sql, join_params, related_table)) = build_m2m_lazy_queries(
367        nav,
368        ctx.owner_key_values(),
369        ref_column,
370        gen,
371        ctx.filter_map(),
372    )?
373    else {
374        return Ok(Vec::new());
375    };
376
377    // Phase 1: query join table for related IDs.
378    let mut conn = provider.get_connection().await?;
379    let join_rows = conn.query(&join_sql, &join_params).await?;
380    if join_rows.is_empty() {
381        return Ok(Vec::new());
382    }
383
384    // Collect unique related IDs from join rows.
385    let related_fk_index = nav.through_related_fk_index;
386    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
387    let related_ids: Vec<DbValue> = join_rows
388        .iter()
389        .filter_map(|row| row.get(related_fk_index).cloned())
390        .filter(|v| seen.insert(format!("{}", v)))
391        .collect();
392
393    if related_ids.is_empty() {
394        return Ok(Vec::new());
395    }
396
397    // Phase 2: SELECT * FROM related_table WHERE pk IN (?, ?, ...).
398    let ref_pk_col = nav
399        .referenced_key_column
400        .as_deref()
401        .map(|s| s.to_string())
402        .or_else(|| {
403            nav.related_entity_meta.and_then(|f| {
404                let meta = f();
405                meta.primary_keys.first().map(|k| k.to_string())
406            })
407        })
408        .unwrap_or_else(|| "id".to_string());
409
410    let placeholders: Vec<String> = (0..related_ids.len())
411        .map(|i| gen.parameter_placeholder(i + 1))
412        .collect();
413    let mut sql = format!(
414        "SELECT * FROM {} WHERE {} IN ({})",
415        related_table,
416        ref_pk_col,
417        placeholders.join(", ")
418    );
419    let mut params: Vec<DbValue> = related_ids;
420    apply_filter(&mut sql, &mut params, &related_table, ctx.filter_map(), gen);
421
422    let rows = conn.query(&sql, &params).await?;
423    let mut entities = Vec::with_capacity(rows.len());
424    for row in rows {
425        entities.push(T::from_row(&row)?);
426    }
427    Ok(entities)
428}