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