Skip to main content

sqlmodel_session/
lib.rs

1//! Session and Unit of Work for SQLModel Rust.
2//!
3//! `sqlmodel-session` is the **unit-of-work layer**. It coordinates object identity,
4//! change tracking, and transactional persistence in a way that mirrors Python SQLModel
5//! while staying explicit and Rust-idiomatic.
6//!
7//! # Role In The Architecture
8//!
9//! - **Identity map**: ensures a single in-memory instance per primary key.
10//! - **Change tracking**: records inserts, updates, and deletes before flush.
11//! - **Transactional safety**: wraps flush/commit/rollback around a `Connection`.
12//!
13//! # Design Philosophy
14//!
15//! - **Explicit over implicit**: No autoflush by default.
16//! - **Ownership clarity**: Session owns the connection or pooled connection.
17//! - **Type erasure**: Identity map stores `Box<dyn Any>` for heterogeneous models.
18//! - **Cancel-correct**: All async operations use `Cx` + `Outcome` via `sqlmodel-core`.
19//!
20//! # Example
21//!
22//! ```ignore
23//! // Create session from pool
24//! let mut session = Session::new(&pool).await?;
25//!
26//! // Add new objects (will be INSERTed on flush)
27//! session.add(&hero);
28//!
29//! // Get by primary key (uses identity map)
30//! let hero = session.get::<Hero>(1).await?;
31//!
32//! // Mark for deletion
33//! session.delete(&hero);
34//!
35//! // Flush pending changes to DB
36//! session.flush().await?;
37//!
38//! // Commit the transaction
39//! session.commit().await?;
40//! ```
41
42pub mod change_tracker;
43pub mod flush;
44pub mod identity_map;
45pub mod n1_detection;
46pub mod unit_of_work;
47
48pub use change_tracker::{ChangeTracker, ObjectSnapshot};
49pub use flush::{
50    FlushOrderer, FlushPlan, FlushResult, LinkTableOp, PendingOp, execute_link_table_ops,
51};
52pub use identity_map::{IdentityMap, ModelReadGuard, ModelRef, ModelWriteGuard, WeakIdentityMap};
53pub use n1_detection::{CallSite, N1DetectionScope, N1QueryTracker, N1Stats};
54pub use unit_of_work::{PendingCounts, UnitOfWork, UowError};
55
56use asupersync::{Cx, Outcome};
57use serde::{Deserialize, Serialize};
58use sqlmodel_core::{Connection, Error, Lazy, LazyLoader, Model, Value};
59use std::any::{Any, TypeId};
60use std::collections::HashMap;
61use std::future::Future;
62use std::hash::{Hash, Hasher};
63
64// ============================================================================
65// Session Events
66// ============================================================================
67
68/// Type alias for session event callbacks.
69///
70/// Callbacks receive no arguments and return `Result<(), Error>`.
71/// Returning `Err` will abort the operation (e.g., prevent commit).
72type SessionEventFn = Box<dyn FnMut() -> Result<(), Error> + Send>;
73
74/// Holds registered session-level event callbacks.
75///
76/// These are fired at key points in the session lifecycle:
77/// before/after flush, commit, and rollback.
78#[derive(Default)]
79pub struct SessionEventCallbacks {
80    before_flush: Vec<SessionEventFn>,
81    after_flush: Vec<SessionEventFn>,
82    before_commit: Vec<SessionEventFn>,
83    after_commit: Vec<SessionEventFn>,
84    after_rollback: Vec<SessionEventFn>,
85}
86
87impl std::fmt::Debug for SessionEventCallbacks {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        f.debug_struct("SessionEventCallbacks")
90            .field("before_flush", &self.before_flush.len())
91            .field("after_flush", &self.after_flush.len())
92            .field("before_commit", &self.before_commit.len())
93            .field("after_commit", &self.after_commit.len())
94            .field("after_rollback", &self.after_rollback.len())
95            .finish()
96    }
97}
98
99impl SessionEventCallbacks {
100    #[allow(clippy::result_large_err)]
101    fn fire(&mut self, event: SessionEvent) -> Result<(), Error> {
102        let callbacks = match event {
103            SessionEvent::BeforeFlush => &mut self.before_flush,
104            SessionEvent::AfterFlush => &mut self.after_flush,
105            SessionEvent::BeforeCommit => &mut self.before_commit,
106            SessionEvent::AfterCommit => &mut self.after_commit,
107            SessionEvent::AfterRollback => &mut self.after_rollback,
108        };
109        for cb in callbacks.iter_mut() {
110            cb()?;
111        }
112        Ok(())
113    }
114}
115
116/// Session lifecycle events.
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub enum SessionEvent {
119    /// Fired before flush executes pending changes.
120    BeforeFlush,
121    /// Fired after flush completes successfully.
122    AfterFlush,
123    /// Fired before commit (after flush).
124    BeforeCommit,
125    /// Fired after commit completes successfully.
126    AfterCommit,
127    /// Fired after rollback completes.
128    AfterRollback,
129}
130
131// ============================================================================
132// Session Configuration
133// ============================================================================
134
135/// Configuration for Session behavior.
136#[derive(Debug, Clone)]
137pub struct SessionConfig {
138    /// Whether to auto-begin a transaction on first operation.
139    pub auto_begin: bool,
140    /// Whether to auto-flush before queries (not recommended for performance).
141    pub auto_flush: bool,
142    /// Whether to expire objects after commit (reload from DB on next access).
143    pub expire_on_commit: bool,
144}
145
146impl Default for SessionConfig {
147    fn default() -> Self {
148        Self {
149            auto_begin: true,
150            auto_flush: false,
151            expire_on_commit: true,
152        }
153    }
154}
155
156/// Options for `Session::get_with_options()`.
157#[derive(Debug, Clone, Default)]
158pub struct GetOptions {
159    /// If true, use SELECT ... FOR UPDATE to lock the row.
160    pub with_for_update: bool,
161    /// If true, use SKIP LOCKED with FOR UPDATE (requires `with_for_update`).
162    pub skip_locked: bool,
163    /// If true, use NOWAIT with FOR UPDATE (requires `with_for_update`).
164    pub nowait: bool,
165}
166
167impl GetOptions {
168    /// Create new default options.
169    #[must_use]
170    pub fn new() -> Self {
171        Self::default()
172    }
173
174    /// Set the `with_for_update` option (builder pattern).
175    #[must_use]
176    pub fn with_for_update(mut self, value: bool) -> Self {
177        self.with_for_update = value;
178        self
179    }
180
181    /// Set the `skip_locked` option (builder pattern).
182    #[must_use]
183    pub fn skip_locked(mut self, value: bool) -> Self {
184        self.skip_locked = value;
185        self
186    }
187
188    /// Set the `nowait` option (builder pattern).
189    #[must_use]
190    pub fn nowait(mut self, value: bool) -> Self {
191        self.nowait = value;
192        self
193    }
194}
195
196// ============================================================================
197// Object Key and State
198// ============================================================================
199
200/// Unique key for an object in the identity map.
201#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
202pub struct ObjectKey {
203    /// Type identifier for the Model type.
204    type_id: TypeId,
205    /// Hash of the primary key value(s).
206    pk_hash: u64,
207}
208
209impl ObjectKey {
210    /// Create an object key from a model instance.
211    pub fn from_model<M: Model + 'static>(obj: &M) -> Self {
212        let pk_values = obj.primary_key_value();
213        Self {
214            type_id: TypeId::of::<M>(),
215            pk_hash: hash_values(&pk_values),
216        }
217    }
218
219    /// Create an object key from type and primary key.
220    pub fn from_pk<M: Model + 'static>(pk: &[Value]) -> Self {
221        Self {
222            type_id: TypeId::of::<M>(),
223            pk_hash: hash_values(pk),
224        }
225    }
226
227    /// Get the primary key hash.
228    pub fn pk_hash(&self) -> u64 {
229        self.pk_hash
230    }
231
232    /// Get the type identifier.
233    pub fn type_id(&self) -> TypeId {
234        self.type_id
235    }
236}
237
238/// Hash a slice of values for use as a primary key hash.
239fn hash_values(values: &[Value]) -> u64 {
240    use std::collections::hash_map::DefaultHasher;
241    let mut hasher = DefaultHasher::new();
242    for v in values {
243        // Hash based on value variant and content
244        match v {
245            Value::Null => 0u8.hash(&mut hasher),
246            Value::Bool(b) => {
247                1u8.hash(&mut hasher);
248                b.hash(&mut hasher);
249            }
250            Value::TinyInt(i) => {
251                2u8.hash(&mut hasher);
252                i.hash(&mut hasher);
253            }
254            Value::SmallInt(i) => {
255                3u8.hash(&mut hasher);
256                i.hash(&mut hasher);
257            }
258            Value::Int(i) => {
259                4u8.hash(&mut hasher);
260                i.hash(&mut hasher);
261            }
262            Value::BigInt(i) => {
263                5u8.hash(&mut hasher);
264                i.hash(&mut hasher);
265            }
266            Value::Float(f) => {
267                6u8.hash(&mut hasher);
268                f.to_bits().hash(&mut hasher);
269            }
270            Value::Double(f) => {
271                7u8.hash(&mut hasher);
272                f.to_bits().hash(&mut hasher);
273            }
274            Value::Decimal(s) => {
275                8u8.hash(&mut hasher);
276                s.hash(&mut hasher);
277            }
278            Value::Text(s) => {
279                9u8.hash(&mut hasher);
280                s.hash(&mut hasher);
281            }
282            Value::Bytes(b) => {
283                10u8.hash(&mut hasher);
284                b.hash(&mut hasher);
285            }
286            Value::Date(d) => {
287                11u8.hash(&mut hasher);
288                d.hash(&mut hasher);
289            }
290            Value::Time(t) => {
291                12u8.hash(&mut hasher);
292                t.hash(&mut hasher);
293            }
294            Value::Timestamp(ts) => {
295                13u8.hash(&mut hasher);
296                ts.hash(&mut hasher);
297            }
298            Value::TimestampTz(ts) => {
299                14u8.hash(&mut hasher);
300                ts.hash(&mut hasher);
301            }
302            Value::Uuid(u) => {
303                15u8.hash(&mut hasher);
304                u.hash(&mut hasher);
305            }
306            Value::Json(j) => {
307                16u8.hash(&mut hasher);
308                // Hash the JSON string representation
309                j.to_string().hash(&mut hasher);
310            }
311            Value::Array(arr) => {
312                17u8.hash(&mut hasher);
313                // Recursively hash array elements
314                arr.len().hash(&mut hasher);
315                for item in arr {
316                    hash_value(item, &mut hasher);
317                }
318            }
319            Value::Default => {
320                18u8.hash(&mut hasher);
321            }
322        }
323    }
324    hasher.finish()
325}
326
327/// Hash a single value into the hasher.
328fn hash_value(v: &Value, hasher: &mut impl Hasher) {
329    match v {
330        Value::Null => 0u8.hash(hasher),
331        Value::Bool(b) => {
332            1u8.hash(hasher);
333            b.hash(hasher);
334        }
335        Value::TinyInt(i) => {
336            2u8.hash(hasher);
337            i.hash(hasher);
338        }
339        Value::SmallInt(i) => {
340            3u8.hash(hasher);
341            i.hash(hasher);
342        }
343        Value::Int(i) => {
344            4u8.hash(hasher);
345            i.hash(hasher);
346        }
347        Value::BigInt(i) => {
348            5u8.hash(hasher);
349            i.hash(hasher);
350        }
351        Value::Float(f) => {
352            6u8.hash(hasher);
353            f.to_bits().hash(hasher);
354        }
355        Value::Double(f) => {
356            7u8.hash(hasher);
357            f.to_bits().hash(hasher);
358        }
359        Value::Decimal(s) => {
360            8u8.hash(hasher);
361            s.hash(hasher);
362        }
363        Value::Text(s) => {
364            9u8.hash(hasher);
365            s.hash(hasher);
366        }
367        Value::Bytes(b) => {
368            10u8.hash(hasher);
369            b.hash(hasher);
370        }
371        Value::Date(d) => {
372            11u8.hash(hasher);
373            d.hash(hasher);
374        }
375        Value::Time(t) => {
376            12u8.hash(hasher);
377            t.hash(hasher);
378        }
379        Value::Timestamp(ts) => {
380            13u8.hash(hasher);
381            ts.hash(hasher);
382        }
383        Value::TimestampTz(ts) => {
384            14u8.hash(hasher);
385            ts.hash(hasher);
386        }
387        Value::Uuid(u) => {
388            15u8.hash(hasher);
389            u.hash(hasher);
390        }
391        Value::Json(j) => {
392            16u8.hash(hasher);
393            j.to_string().hash(hasher);
394        }
395        Value::Array(arr) => {
396            17u8.hash(hasher);
397            arr.len().hash(hasher);
398            for item in arr {
399                hash_value(item, hasher);
400            }
401        }
402        Value::Default => {
403            18u8.hash(hasher);
404        }
405    }
406}
407
408/// State of a tracked object in the session.
409#[derive(Debug, Clone, Copy, PartialEq, Eq)]
410pub enum ObjectState {
411    /// New object, needs INSERT on flush.
412    New,
413    /// Persistent object loaded from database.
414    Persistent,
415    /// Object marked for deletion, needs DELETE on flush.
416    Deleted,
417    /// Object detached from session.
418    Detached,
419    /// Object expired, needs reload from database.
420    Expired,
421}
422
423/// A tracked object in the session.
424struct TrackedObject {
425    /// The actual object (type-erased).
426    object: Box<dyn Any + Send + Sync>,
427    /// Original serialized state for dirty checking.
428    original_state: Option<Vec<u8>>,
429    /// Current object state.
430    state: ObjectState,
431    /// Table name for this object.
432    table_name: &'static str,
433    /// Column names for this object.
434    column_names: Vec<&'static str>,
435    /// Current values for each column (for INSERT/UPDATE).
436    values: Vec<Value>,
437    /// Primary key column names.
438    pk_columns: Vec<&'static str>,
439    /// Primary key values (for DELETE/UPDATE WHERE clause).
440    pk_values: Vec<Value>,
441    /// Static relationship metadata for this object's model type.
442    relationships: &'static [sqlmodel_core::RelationshipInfo],
443    /// Set of expired attribute names (None = all expired, Some(empty) = none expired).
444    /// When Some(non-empty), only those specific attributes need reload.
445    expired_attributes: Option<std::collections::HashSet<String>>,
446}
447
448#[derive(Debug, Clone, PartialEq, Eq, Hash)]
449struct CascadeChildDeleteKey {
450    table: &'static str,
451    fk_cols: Vec<&'static str>,
452}
453
454// ============================================================================
455// Session
456// ============================================================================
457
458/// The Session is the central unit-of-work manager.
459///
460/// It tracks objects loaded from or added to the database and coordinates
461/// flushing changes back to the database.
462pub struct Session<C: Connection> {
463    /// The database connection.
464    connection: C,
465    /// Whether we're in a transaction.
466    in_transaction: bool,
467    /// Identity map: ObjectKey -> TrackedObject.
468    identity_map: HashMap<ObjectKey, TrackedObject>,
469    /// Objects marked as new (need INSERT).
470    pending_new: Vec<ObjectKey>,
471    /// Objects marked as deleted (need DELETE).
472    pending_delete: Vec<ObjectKey>,
473    /// Objects that are dirty (need UPDATE).
474    pending_dirty: Vec<ObjectKey>,
475    /// Configuration.
476    config: SessionConfig,
477    /// N+1 query detection tracker (optional).
478    n1_tracker: Option<N1QueryTracker>,
479    /// Session-level event callbacks.
480    event_callbacks: SessionEventCallbacks,
481}
482
483impl<C: Connection> Session<C> {
484    /// Create a new session from an existing connection.
485    pub fn new(connection: C) -> Self {
486        Self::with_config(connection, SessionConfig::default())
487    }
488
489    /// Create a new session with custom configuration.
490    pub fn with_config(connection: C, config: SessionConfig) -> Self {
491        Self {
492            connection,
493            in_transaction: false,
494            identity_map: HashMap::new(),
495            pending_new: Vec::new(),
496            pending_delete: Vec::new(),
497            pending_dirty: Vec::new(),
498            config,
499            n1_tracker: None,
500            event_callbacks: SessionEventCallbacks::default(),
501        }
502    }
503
504    /// Get a reference to the underlying connection.
505    pub fn connection(&self) -> &C {
506        &self.connection
507    }
508
509    /// Get the session configuration.
510    pub fn config(&self) -> &SessionConfig {
511        &self.config
512    }
513
514    // ========================================================================
515    // Session Events
516    // ========================================================================
517
518    /// Register a callback to run before flush.
519    ///
520    /// The callback can abort the flush by returning `Err`.
521    pub fn on_before_flush(&mut self, f: impl FnMut() -> Result<(), Error> + Send + 'static) {
522        self.event_callbacks.before_flush.push(Box::new(f));
523    }
524
525    /// Register a callback to run after a successful flush.
526    pub fn on_after_flush(&mut self, f: impl FnMut() -> Result<(), Error> + Send + 'static) {
527        self.event_callbacks.after_flush.push(Box::new(f));
528    }
529
530    /// Register a callback to run before commit (after flush).
531    ///
532    /// The callback can abort the commit by returning `Err`.
533    pub fn on_before_commit(&mut self, f: impl FnMut() -> Result<(), Error> + Send + 'static) {
534        self.event_callbacks.before_commit.push(Box::new(f));
535    }
536
537    /// Register a callback to run after a successful commit.
538    pub fn on_after_commit(&mut self, f: impl FnMut() -> Result<(), Error> + Send + 'static) {
539        self.event_callbacks.after_commit.push(Box::new(f));
540    }
541
542    /// Register a callback to run after rollback.
543    pub fn on_after_rollback(&mut self, f: impl FnMut() -> Result<(), Error> + Send + 'static) {
544        self.event_callbacks.after_rollback.push(Box::new(f));
545    }
546
547    // ========================================================================
548    // Object Tracking
549    // ========================================================================
550
551    /// Add a new object to the session.
552    ///
553    /// The object will be INSERTed on the next `flush()` call.
554    pub fn add<M: Model + Clone + Send + Sync + Serialize + 'static>(&mut self, obj: &M) {
555        let key = ObjectKey::from_model(obj);
556
557        // If already tracked, update the object and its values
558        if let Some(tracked) = self.identity_map.get_mut(&key) {
559            tracked.object = Box::new(obj.clone());
560
561            // Update stored values to match the new object state
562            let row_data = obj.to_row();
563            tracked.column_names = row_data.iter().map(|(name, _)| *name).collect();
564            tracked.values = row_data.into_iter().map(|(_, v)| v).collect();
565            tracked.pk_values = obj.primary_key_value();
566
567            if tracked.state == ObjectState::Deleted {
568                // Un-delete: remove from pending_delete and restore state
569                self.pending_delete.retain(|k| k != &key);
570
571                if tracked.original_state.is_some() {
572                    // Was previously persisted - restore to Persistent (will need UPDATE if changed)
573                    tracked.state = ObjectState::Persistent;
574                } else {
575                    // Was never persisted - restore to New and schedule for INSERT
576                    tracked.state = ObjectState::New;
577                    if !self.pending_new.contains(&key) {
578                        self.pending_new.push(key);
579                    }
580                }
581            }
582            return;
583        }
584
585        // Extract column data from the model while we have the concrete type
586        let row_data = obj.to_row();
587        let column_names: Vec<&'static str> = row_data.iter().map(|(name, _)| *name).collect();
588        let values: Vec<Value> = row_data.into_iter().map(|(_, v)| v).collect();
589
590        // Extract primary key info
591        let pk_columns: Vec<&'static str> = M::PRIMARY_KEY.to_vec();
592        let pk_values = obj.primary_key_value();
593
594        let tracked = TrackedObject {
595            object: Box::new(obj.clone()),
596            original_state: None, // New objects have no original state
597            state: ObjectState::New,
598            table_name: M::TABLE_NAME,
599            column_names,
600            values,
601            pk_columns,
602            pk_values,
603            relationships: M::RELATIONSHIPS,
604            expired_attributes: None,
605        };
606
607        self.identity_map.insert(key, tracked);
608        self.pending_new.push(key);
609    }
610
611    /// Add multiple objects to the session at once.
612    ///
613    /// This is equivalent to calling `add()` for each object, but provides a more
614    /// convenient API for bulk operations.
615    ///
616    /// # Example
617    ///
618    /// ```ignore
619    /// let users = vec![user1, user2, user3];
620    /// session.add_all(&users);
621    ///
622    /// // Or with an iterator
623    /// session.add_all(users.iter());
624    /// ```
625    ///
626    /// All objects will be INSERTed on the next `flush()` call.
627    pub fn add_all<'a, M, I>(&mut self, objects: I)
628    where
629        M: Model + Clone + Send + Sync + Serialize + 'static,
630        I: IntoIterator<Item = &'a M>,
631    {
632        for obj in objects {
633            self.add(obj);
634        }
635    }
636
637    /// Delete an object from the session.
638    ///
639    /// The object will be DELETEd on the next `flush()` call.
640    pub fn delete<M: Model + 'static>(&mut self, obj: &M) {
641        let key = ObjectKey::from_model(obj);
642
643        if let Some(tracked) = self.identity_map.get_mut(&key) {
644            match tracked.state {
645                ObjectState::New => {
646                    // If it's new, just remove it entirely
647                    self.identity_map.remove(&key);
648                    self.pending_new.retain(|k| k != &key);
649                }
650                ObjectState::Persistent | ObjectState::Expired => {
651                    tracked.state = ObjectState::Deleted;
652                    self.pending_delete.push(key);
653                    self.pending_dirty.retain(|k| k != &key);
654                }
655                ObjectState::Deleted | ObjectState::Detached => {
656                    // Already deleted or detached, nothing to do
657                }
658            }
659        }
660    }
661
662    /// Mark an object as dirty (modified) so it will be UPDATEd on flush.
663    ///
664    /// This updates the stored values from the object and schedules an UPDATE.
665    /// Only works for objects that are already tracked as Persistent.
666    ///
667    /// # Example
668    ///
669    /// ```ignore
670    /// let mut hero = session.get::<Hero>(1).await?.unwrap();
671    /// hero.name = "New Name".to_string();
672    /// session.mark_dirty(&hero);  // Schedule for UPDATE
673    /// session.flush(cx).await?;   // Execute the UPDATE
674    /// ```
675    pub fn mark_dirty<M: Model + Clone + Send + Sync + Serialize + 'static>(&mut self, obj: &M) {
676        let key = ObjectKey::from_model(obj);
677
678        if let Some(tracked) = self.identity_map.get_mut(&key) {
679            // Only mark persistent objects as dirty
680            if tracked.state != ObjectState::Persistent {
681                return;
682            }
683
684            // Update the stored object and values
685            tracked.object = Box::new(obj.clone());
686            let row_data = obj.to_row();
687            tracked.column_names = row_data.iter().map(|(name, _)| *name).collect();
688            tracked.values = row_data.into_iter().map(|(_, v)| v).collect();
689            tracked.pk_values = obj.primary_key_value();
690
691            // Add to pending dirty if not already there
692            if !self.pending_dirty.contains(&key) {
693                self.pending_dirty.push(key);
694            }
695        }
696    }
697
698    /// Get an object by primary key.
699    ///
700    /// First checks the identity map, then queries the database if not found.
701    pub async fn get<
702        M: Model + Clone + Send + Sync + Serialize + for<'de> Deserialize<'de> + 'static,
703    >(
704        &mut self,
705        cx: &Cx,
706        pk: impl Into<Value>,
707    ) -> Outcome<Option<M>, Error> {
708        let pk_value = pk.into();
709        let pk_values = vec![pk_value.clone()];
710        let key = ObjectKey::from_pk::<M>(&pk_values);
711
712        // Check identity map first (skip if expired - will reload below)
713        if let Some(tracked) = self.identity_map.get(&key) {
714            match tracked.state {
715                ObjectState::Deleted | ObjectState::Detached => {
716                    // Return None for deleted/detached objects
717                }
718                ObjectState::Expired => {
719                    // Skip cache, will reload from DB below
720                    tracing::debug!("Object is expired, reloading from database");
721                }
722                ObjectState::New | ObjectState::Persistent => {
723                    if let Some(obj) = tracked.object.downcast_ref::<M>() {
724                        return Outcome::Ok(Some(obj.clone()));
725                    }
726                }
727            }
728        }
729
730        // Query from database
731        let pk_col = M::PRIMARY_KEY.first().unwrap_or(&"id");
732        let sql = format!(
733            "SELECT * FROM \"{}\" WHERE \"{}\" = $1 LIMIT 1",
734            M::TABLE_NAME,
735            pk_col
736        );
737
738        let rows = match self.connection.query(cx, &sql, &[pk_value]).await {
739            Outcome::Ok(rows) => rows,
740            Outcome::Err(e) => return Outcome::Err(e),
741            Outcome::Cancelled(r) => return Outcome::Cancelled(r),
742            Outcome::Panicked(p) => return Outcome::Panicked(p),
743        };
744
745        if rows.is_empty() {
746            return Outcome::Ok(None);
747        }
748
749        // Convert row to model
750        let obj = match M::from_row(&rows[0]) {
751            Ok(obj) => obj,
752            Err(e) => return Outcome::Err(e),
753        };
754
755        // Extract column data from the model while we have the concrete type
756        let row_data = obj.to_row();
757        let column_names: Vec<&'static str> = row_data.iter().map(|(name, _)| *name).collect();
758        let values: Vec<Value> = row_data.into_iter().map(|(_, v)| v).collect();
759
760        // Serialize values for dirty checking (must match format used in flush)
761        let serialized = serde_json::to_vec(&values).ok();
762
763        // Extract primary key info
764        let pk_columns: Vec<&'static str> = M::PRIMARY_KEY.to_vec();
765        let obj_pk_values = obj.primary_key_value();
766
767        let tracked = TrackedObject {
768            object: Box::new(obj.clone()),
769            original_state: serialized,
770            state: ObjectState::Persistent,
771            table_name: M::TABLE_NAME,
772            column_names,
773            values,
774            pk_columns,
775            pk_values: obj_pk_values,
776            relationships: M::RELATIONSHIPS,
777            expired_attributes: None,
778        };
779
780        self.identity_map.insert(key, tracked);
781
782        Outcome::Ok(Some(obj))
783    }
784
785    /// Get an object by composite primary key.
786    ///
787    /// First checks the identity map, then queries the database if not found.
788    ///
789    /// # Example
790    ///
791    /// ```ignore
792    /// // Composite PK lookup
793    /// let item = session.get_by_pk::<OrderItem>(&[
794    ///     Value::BigInt(order_id),
795    ///     Value::BigInt(product_id),
796    /// ]).await?;
797    /// ```
798    pub async fn get_by_pk<
799        M: Model + Clone + Send + Sync + Serialize + for<'de> Deserialize<'de> + 'static,
800    >(
801        &mut self,
802        cx: &Cx,
803        pk_values: &[Value],
804    ) -> Outcome<Option<M>, Error> {
805        self.get_with_options::<M>(cx, pk_values, &GetOptions::default())
806            .await
807    }
808
809    /// Get an object by primary key with options.
810    ///
811    /// This is the most flexible form of `get()` supporting:
812    /// - Composite primary keys via `&[Value]`
813    /// - `with_for_update` for row locking
814    ///
815    /// # Example
816    ///
817    /// ```ignore
818    /// let options = GetOptions::default().with_for_update(true);
819    /// let user = session.get_with_options::<User>(&[Value::BigInt(1)], &options).await?;
820    /// ```
821    pub async fn get_with_options<
822        M: Model + Clone + Send + Sync + Serialize + for<'de> Deserialize<'de> + 'static,
823    >(
824        &mut self,
825        cx: &Cx,
826        pk_values: &[Value],
827        options: &GetOptions,
828    ) -> Outcome<Option<M>, Error> {
829        let key = ObjectKey::from_pk::<M>(pk_values);
830
831        // Check identity map first (unless with_for_update which needs fresh DB state)
832        if !options.with_for_update
833            && let Some(tracked) = self.identity_map.get(&key)
834        {
835            match tracked.state {
836                ObjectState::Deleted | ObjectState::Detached => {
837                    // Return None for deleted/detached objects
838                }
839                ObjectState::Expired => {
840                    // Skip cache, will reload from DB below
841                    tracing::debug!("Object is expired, reloading from database");
842                }
843                ObjectState::New | ObjectState::Persistent => {
844                    if let Some(obj) = tracked.object.downcast_ref::<M>() {
845                        return Outcome::Ok(Some(obj.clone()));
846                    }
847                }
848            }
849        }
850
851        // Build WHERE clause for composite PK
852        let pk_columns = M::PRIMARY_KEY;
853        if pk_columns.len() != pk_values.len() {
854            return Outcome::Err(Error::Custom(format!(
855                "Primary key mismatch: expected {} values, got {}",
856                pk_columns.len(),
857                pk_values.len()
858            )));
859        }
860
861        let where_parts: Vec<String> = pk_columns
862            .iter()
863            .enumerate()
864            .map(|(i, col)| format!("\"{}\" = ${}", col, i + 1))
865            .collect();
866
867        let mut sql = format!(
868            "SELECT * FROM \"{}\" WHERE {} LIMIT 1",
869            M::TABLE_NAME,
870            where_parts.join(" AND ")
871        );
872
873        // Add FOR UPDATE if requested
874        if options.with_for_update {
875            sql.push_str(" FOR UPDATE");
876            if options.skip_locked {
877                sql.push_str(" SKIP LOCKED");
878            } else if options.nowait {
879                sql.push_str(" NOWAIT");
880            }
881        }
882
883        let rows = match self.connection.query(cx, &sql, pk_values).await {
884            Outcome::Ok(rows) => rows,
885            Outcome::Err(e) => return Outcome::Err(e),
886            Outcome::Cancelled(r) => return Outcome::Cancelled(r),
887            Outcome::Panicked(p) => return Outcome::Panicked(p),
888        };
889
890        if rows.is_empty() {
891            return Outcome::Ok(None);
892        }
893
894        // Convert row to model
895        let obj = match M::from_row(&rows[0]) {
896            Ok(obj) => obj,
897            Err(e) => return Outcome::Err(e),
898        };
899
900        // Extract column data from the model while we have the concrete type
901        let row_data = obj.to_row();
902        let column_names: Vec<&'static str> = row_data.iter().map(|(name, _)| *name).collect();
903        let values: Vec<Value> = row_data.into_iter().map(|(_, v)| v).collect();
904
905        // Serialize values for dirty checking
906        let serialized = serde_json::to_vec(&values).ok();
907
908        // Extract primary key info
909        let pk_cols: Vec<&'static str> = M::PRIMARY_KEY.to_vec();
910        let obj_pk_values = obj.primary_key_value();
911
912        let tracked = TrackedObject {
913            object: Box::new(obj.clone()),
914            original_state: serialized,
915            state: ObjectState::Persistent,
916            table_name: M::TABLE_NAME,
917            column_names,
918            values,
919            pk_columns: pk_cols,
920            pk_values: obj_pk_values,
921            relationships: M::RELATIONSHIPS,
922            expired_attributes: None,
923        };
924
925        self.identity_map.insert(key, tracked);
926
927        Outcome::Ok(Some(obj))
928    }
929
930    /// Check if an object is tracked by this session.
931    pub fn contains<M: Model + 'static>(&self, obj: &M) -> bool {
932        let key = ObjectKey::from_model(obj);
933        self.identity_map.contains_key(&key)
934    }
935
936    /// Detach an object from the session.
937    pub fn expunge<M: Model + 'static>(&mut self, obj: &M) {
938        let key = ObjectKey::from_model(obj);
939        if let Some(tracked) = self.identity_map.get_mut(&key) {
940            tracked.state = ObjectState::Detached;
941        }
942        self.pending_new.retain(|k| k != &key);
943        self.pending_delete.retain(|k| k != &key);
944        self.pending_dirty.retain(|k| k != &key);
945    }
946
947    /// Detach all objects from the session.
948    pub fn expunge_all(&mut self) {
949        for tracked in self.identity_map.values_mut() {
950            tracked.state = ObjectState::Detached;
951        }
952        self.pending_new.clear();
953        self.pending_delete.clear();
954        self.pending_dirty.clear();
955    }
956
957    // ========================================================================
958    // Dirty Checking
959    // ========================================================================
960
961    /// Check if an object has pending changes.
962    ///
963    /// Returns `true` if:
964    /// - Object is new (pending INSERT)
965    /// - Object has been modified since load (pending UPDATE)
966    /// - Object is marked for deletion (pending DELETE)
967    ///
968    /// Returns `false` if:
969    /// - Object is not tracked
970    /// - Object is clean (unchanged since load)
971    /// - Object is detached or expired
972    ///
973    /// # Example
974    ///
975    /// ```ignore
976    /// let user = session.get::<User>(1).await?.unwrap();
977    /// assert!(!session.is_modified(&user));  // Fresh from DB
978    ///
979    /// // Modify and re-check
980    /// let mut user_mut = user.clone();
981    /// user_mut.name = "New Name".to_string();
982    /// session.mark_dirty(&user_mut);
983    /// assert!(session.is_modified(&user_mut));  // Now dirty
984    /// ```
985    pub fn is_modified<M: Model + Serialize + 'static>(&self, obj: &M) -> bool {
986        let key = ObjectKey::from_model(obj);
987
988        let Some(tracked) = self.identity_map.get(&key) else {
989            return false;
990        };
991
992        match tracked.state {
993            // New objects are always "modified" (pending INSERT)
994            ObjectState::New => true,
995
996            // Deleted objects are "modified" (pending DELETE)
997            ObjectState::Deleted => true,
998
999            // Detached/expired objects aren't modified in session context
1000            ObjectState::Detached | ObjectState::Expired => false,
1001
1002            // For persistent objects, compare current values to original
1003            ObjectState::Persistent => {
1004                // Check if explicitly marked dirty
1005                if self.pending_dirty.contains(&key) {
1006                    return true;
1007                }
1008
1009                // Compare serialized values
1010                let current_state = serde_json::to_vec(&tracked.values).unwrap_or_default();
1011                tracked.original_state.as_ref() != Some(&current_state)
1012            }
1013        }
1014    }
1015
1016    /// Get the list of modified attribute names for an object.
1017    ///
1018    /// Returns the column names that have changed since the object was loaded.
1019    /// Returns an empty vector if:
1020    /// - Object is not tracked
1021    /// - Object is new (all fields are "modified")
1022    /// - Object is clean (no changes)
1023    ///
1024    /// # Example
1025    ///
1026    /// ```ignore
1027    /// let mut user = session.get::<User>(1).await?.unwrap();
1028    /// user.name = "New Name".to_string();
1029    /// session.mark_dirty(&user);
1030    ///
1031    /// let changed = session.modified_attributes(&user);
1032    /// assert!(changed.contains(&"name"));
1033    /// ```
1034    pub fn modified_attributes<M: Model + Serialize + 'static>(
1035        &self,
1036        obj: &M,
1037    ) -> Vec<&'static str> {
1038        let key = ObjectKey::from_model(obj);
1039
1040        let Some(tracked) = self.identity_map.get(&key) else {
1041            return Vec::new();
1042        };
1043
1044        // Only meaningful for persistent objects
1045        if tracked.state != ObjectState::Persistent {
1046            return Vec::new();
1047        }
1048
1049        // Need original state for comparison
1050        let Some(original_bytes) = &tracked.original_state else {
1051            return Vec::new();
1052        };
1053
1054        // Deserialize original values
1055        let Ok(original_values): Result<Vec<Value>, _> = serde_json::from_slice(original_bytes)
1056        else {
1057            return Vec::new();
1058        };
1059
1060        // Compare each column
1061        let mut modified = Vec::new();
1062        for (i, col) in tracked.column_names.iter().enumerate() {
1063            let current = tracked.values.get(i);
1064            let original = original_values.get(i);
1065
1066            if current != original {
1067                modified.push(*col);
1068            }
1069        }
1070
1071        modified
1072    }
1073
1074    /// Get the state of a tracked object.
1075    ///
1076    /// Returns `None` if the object is not tracked by this session.
1077    pub fn object_state<M: Model + 'static>(&self, obj: &M) -> Option<ObjectState> {
1078        let key = ObjectKey::from_model(obj);
1079        self.identity_map.get(&key).map(|t| t.state)
1080    }
1081
1082    // ========================================================================
1083    // Expiration
1084    // ========================================================================
1085
1086    /// Expire an object's cached attributes, forcing reload on next access.
1087    ///
1088    /// After calling this method, the next `get()` call for this object will reload
1089    /// from the database instead of returning the cached version.
1090    ///
1091    /// # Arguments
1092    ///
1093    /// * `obj` - The object to expire.
1094    /// * `attributes` - Optional list of attribute names to expire. If `None`, all
1095    ///   attributes are expired.
1096    ///
1097    /// # Example
1098    ///
1099    /// ```ignore
1100    /// // Expire all attributes
1101    /// session.expire(&user, None);
1102    ///
1103    /// // Expire specific attributes
1104    /// session.expire(&user, Some(&["name", "email"]));
1105    ///
1106    /// // Next get() will reload from database
1107    /// let refreshed = session.get::<User>(cx, user.id).await?;
1108    /// ```
1109    ///
1110    /// # Notes
1111    ///
1112    /// - Expiring an object does not discard pending changes. If the object has been
1113    ///   modified but not flushed, those changes remain pending.
1114    /// - Expiring a detached or new object has no effect.
1115    #[tracing::instrument(level = "debug", skip(self, obj), fields(table = M::TABLE_NAME))]
1116    pub fn expire<M: Model + 'static>(&mut self, obj: &M, attributes: Option<&[&str]>) {
1117        let key = ObjectKey::from_model(obj);
1118
1119        let Some(tracked) = self.identity_map.get_mut(&key) else {
1120            tracing::debug!("Object not tracked, nothing to expire");
1121            return;
1122        };
1123
1124        // Only expire persistent objects
1125        match tracked.state {
1126            ObjectState::New | ObjectState::Detached | ObjectState::Deleted => {
1127                tracing::debug!(state = ?tracked.state, "Cannot expire object in this state");
1128                return;
1129            }
1130            ObjectState::Persistent | ObjectState::Expired => {}
1131        }
1132
1133        match attributes {
1134            None => {
1135                // Expire all attributes
1136                tracked.state = ObjectState::Expired;
1137                tracked.expired_attributes = None;
1138                tracing::debug!("Expired all attributes");
1139            }
1140            Some(attrs) => {
1141                // Expire specific attributes
1142                let mut expired = tracked.expired_attributes.take().unwrap_or_default();
1143                for attr in attrs {
1144                    expired.insert((*attr).to_string());
1145                }
1146                tracked.expired_attributes = Some(expired);
1147
1148                // If any attributes are expired, mark the object as expired
1149                if tracked.state == ObjectState::Persistent {
1150                    tracked.state = ObjectState::Expired;
1151                }
1152                tracing::debug!(attributes = ?attrs, "Expired specific attributes");
1153            }
1154        }
1155    }
1156
1157    /// Expire all objects in the session.
1158    ///
1159    /// After calling this method, all tracked objects will be marked as expired.
1160    /// The next access to any object will reload from the database.
1161    ///
1162    /// # Example
1163    ///
1164    /// ```ignore
1165    /// // Expire everything in the session
1166    /// session.expire_all();
1167    ///
1168    /// // All subsequent get() calls will reload from database
1169    /// let user = session.get::<User>(cx, 1).await?;  // Reloads from DB
1170    /// let team = session.get::<Team>(cx, 1).await?;  // Reloads from DB
1171    /// ```
1172    ///
1173    /// # Notes
1174    ///
1175    /// - This does not affect new or deleted objects.
1176    /// - Pending changes are not discarded.
1177    #[tracing::instrument(level = "debug", skip(self))]
1178    pub fn expire_all(&mut self) {
1179        let mut expired_count = 0;
1180        for tracked in self.identity_map.values_mut() {
1181            if tracked.state == ObjectState::Persistent {
1182                tracked.state = ObjectState::Expired;
1183                tracked.expired_attributes = None;
1184                expired_count += 1;
1185            }
1186        }
1187        tracing::debug!(count = expired_count, "Expired all session objects");
1188    }
1189
1190    /// Check if an object is expired (needs reload from database).
1191    ///
1192    /// Returns `true` if the object is marked as expired and will be reloaded
1193    /// on the next access.
1194    pub fn is_expired<M: Model + 'static>(&self, obj: &M) -> bool {
1195        let key = ObjectKey::from_model(obj);
1196        self.identity_map
1197            .get(&key)
1198            .is_some_and(|t| t.state == ObjectState::Expired)
1199    }
1200
1201    /// Get the list of expired attribute names for an object.
1202    ///
1203    /// Returns:
1204    /// - `None` if the object is not tracked or not expired
1205    /// - `Some(None)` if all attributes are expired
1206    /// - `Some(Some(set))` if only specific attributes are expired
1207    pub fn expired_attributes<M: Model + 'static>(
1208        &self,
1209        obj: &M,
1210    ) -> Option<Option<&std::collections::HashSet<String>>> {
1211        let key = ObjectKey::from_model(obj);
1212        let tracked = self.identity_map.get(&key)?;
1213
1214        if tracked.state != ObjectState::Expired {
1215            return None;
1216        }
1217
1218        Some(tracked.expired_attributes.as_ref())
1219    }
1220
1221    /// Refresh an object by reloading it from the database.
1222    ///
1223    /// This method immediately reloads the object from the database, updating
1224    /// the cached copy in the session. Unlike `expire()`, which defers the reload
1225    /// until the next access, `refresh()` performs the reload immediately.
1226    ///
1227    /// # Arguments
1228    ///
1229    /// * `cx` - The async context for database operations.
1230    /// * `obj` - The object to refresh.
1231    ///
1232    /// # Returns
1233    ///
1234    /// Returns `Ok(Some(refreshed))` if the object was found in the database,
1235    /// `Ok(None)` if the object no longer exists in the database, or an error.
1236    ///
1237    /// # Example
1238    ///
1239    /// ```ignore
1240    /// // Immediately reload from database
1241    /// let refreshed = session.refresh(&cx, &user).await?;
1242    ///
1243    /// if let Some(user) = refreshed {
1244    ///     println!("Refreshed: {}", user.name);
1245    /// } else {
1246    ///     println!("User was deleted from database");
1247    /// }
1248    /// ```
1249    ///
1250    /// # Notes
1251    ///
1252    /// - This discards any changes in the session's cached copy.
1253    /// - If the object has pending changes, they will be lost.
1254    /// - If the object no longer exists in the database, it is removed from the session.
1255    #[tracing::instrument(level = "debug", skip(self, cx, obj), fields(table = M::TABLE_NAME))]
1256    pub async fn refresh<
1257        M: Model + Clone + Send + Sync + Serialize + for<'de> Deserialize<'de> + 'static,
1258    >(
1259        &mut self,
1260        cx: &Cx,
1261        obj: &M,
1262    ) -> Outcome<Option<M>, Error> {
1263        let pk_values = obj.primary_key_value();
1264        let key = ObjectKey::from_model(obj);
1265
1266        tracing::debug!(pk = ?pk_values, "Refreshing object from database");
1267
1268        // Remove from pending queues since we're reloading
1269        self.pending_dirty.retain(|k| k != &key);
1270
1271        // Remove from identity map to force reload
1272        self.identity_map.remove(&key);
1273
1274        // Reload from database
1275        let result = self.get_by_pk::<M>(cx, &pk_values).await;
1276
1277        match &result {
1278            Outcome::Ok(Some(_)) => {
1279                tracing::debug!("Object refreshed successfully");
1280            }
1281            Outcome::Ok(None) => {
1282                tracing::debug!("Object no longer exists in database");
1283            }
1284            _ => {}
1285        }
1286
1287        result
1288    }
1289
1290    // ========================================================================
1291    // Transaction Management
1292    // ========================================================================
1293
1294    /// Begin a transaction.
1295    pub async fn begin(&mut self, cx: &Cx) -> Outcome<(), Error> {
1296        if self.in_transaction {
1297            return Outcome::Ok(());
1298        }
1299
1300        match self.connection.execute(cx, "BEGIN", &[]).await {
1301            Outcome::Ok(_) => {
1302                self.in_transaction = true;
1303                Outcome::Ok(())
1304            }
1305            Outcome::Err(e) => Outcome::Err(e),
1306            Outcome::Cancelled(r) => Outcome::Cancelled(r),
1307            Outcome::Panicked(p) => Outcome::Panicked(p),
1308        }
1309    }
1310
1311    /// Flush pending changes to the database.
1312    ///
1313    /// This executes INSERT, UPDATE, and DELETE statements but does NOT commit.
1314    pub async fn flush(&mut self, cx: &Cx) -> Outcome<(), Error> {
1315        // Fire before_flush event
1316        if let Err(e) = self.event_callbacks.fire(SessionEvent::BeforeFlush) {
1317            return Outcome::Err(e);
1318        }
1319
1320        // Auto-begin transaction if configured
1321        if self.config.auto_begin && !self.in_transaction {
1322            match self.begin(cx).await {
1323                Outcome::Ok(()) => {}
1324                Outcome::Err(e) => return Outcome::Err(e),
1325                Outcome::Cancelled(r) => return Outcome::Cancelled(r),
1326                Outcome::Panicked(p) => return Outcome::Panicked(p),
1327            }
1328        }
1329
1330        let dialect = self.connection.dialect();
1331
1332        // 1. Execute DELETEs first (to respect FK constraints), including explicit cascades.
1333        let deletes: Vec<ObjectKey> = std::mem::take(&mut self.pending_delete);
1334
1335        // Cascade planning: use relationship metadata on each deleted parent to proactively
1336        // delete dependent rows (and clean up link tables) when `passive_deletes` is not set.
1337        //
1338        // This is intentionally explicit (no hidden queries): we emit concrete DELETE statements.
1339        let mut cascade_child_deletes_single: HashMap<(&'static str, &'static str), Vec<Value>> =
1340            HashMap::new();
1341        let mut cascade_child_deletes_composite: HashMap<CascadeChildDeleteKey, Vec<Vec<Value>>> =
1342            HashMap::new();
1343        let mut cascade_link_deletes_single: HashMap<(&'static str, &'static str), Vec<Value>> =
1344            HashMap::new();
1345        let mut cascade_link_deletes_composite: HashMap<CascadeChildDeleteKey, Vec<Vec<Value>>> =
1346            HashMap::new();
1347
1348        for key in &deletes {
1349            let Some(tracked) = self.identity_map.get(key) else {
1350                continue;
1351            };
1352            if tracked.state != ObjectState::Deleted {
1353                continue;
1354            }
1355            let parent_pk_values = tracked.pk_values.clone();
1356
1357            for rel in tracked.relationships {
1358                if !rel.cascade_delete || rel.is_passive_deletes_all() {
1359                    continue;
1360                }
1361
1362                match rel.kind {
1363                    sqlmodel_core::RelationshipKind::OneToMany
1364                    | sqlmodel_core::RelationshipKind::OneToOne => {
1365                        // With passive_deletes, the DB will delete children when the parent is deleted.
1366                        // Orphan tracking for Passive is handled after the parent delete succeeds.
1367                        if matches!(rel.passive_deletes, sqlmodel_core::PassiveDeletes::Passive) {
1368                            continue;
1369                        }
1370                        let fk_cols = rel.remote_key_cols();
1371                        if fk_cols.is_empty() {
1372                            continue;
1373                        }
1374                        if fk_cols.len() == 1 && parent_pk_values.len() == 1 {
1375                            cascade_child_deletes_single
1376                                .entry((rel.related_table, fk_cols[0]))
1377                                .or_default()
1378                                .push(parent_pk_values[0].clone());
1379                        } else {
1380                            // Composite FK: column order must match parent PK value ordering.
1381                            if fk_cols.len() != parent_pk_values.len() {
1382                                continue;
1383                            }
1384                            cascade_child_deletes_composite
1385                                .entry(CascadeChildDeleteKey {
1386                                    table: rel.related_table,
1387                                    fk_cols: fk_cols.to_vec(),
1388                                })
1389                                .or_default()
1390                                .push(parent_pk_values.clone());
1391                        }
1392                    }
1393                    sqlmodel_core::RelationshipKind::ManyToMany => {
1394                        if matches!(rel.passive_deletes, sqlmodel_core::PassiveDeletes::Passive) {
1395                            continue;
1396                        }
1397                        let Some(link) = rel.link_table else {
1398                            continue;
1399                        };
1400                        let local_cols = link.local_cols();
1401                        if local_cols.is_empty() {
1402                            continue;
1403                        }
1404                        if local_cols.len() == 1 && parent_pk_values.len() == 1 {
1405                            cascade_link_deletes_single
1406                                .entry((link.table_name, local_cols[0]))
1407                                .or_default()
1408                                .push(parent_pk_values[0].clone());
1409                        } else {
1410                            if local_cols.len() != parent_pk_values.len() {
1411                                continue;
1412                            }
1413                            cascade_link_deletes_composite
1414                                .entry(CascadeChildDeleteKey {
1415                                    table: link.table_name,
1416                                    fk_cols: local_cols.to_vec(),
1417                                })
1418                                .or_default()
1419                                .push(parent_pk_values.clone());
1420                        }
1421                    }
1422                    sqlmodel_core::RelationshipKind::ManyToOne => {}
1423                }
1424            }
1425        }
1426
1427        let dedup_by_hash = |vals: &mut Vec<Value>| {
1428            let mut seen: std::collections::HashSet<u64> = std::collections::HashSet::new();
1429            vals.retain(|v| seen.insert(hash_values(std::slice::from_ref(v))));
1430        };
1431
1432        // (a) Delete children first (one-to-many / one-to-one).
1433        for ((child_table, fk_col), mut pks) in cascade_child_deletes_single {
1434            dedup_by_hash(&mut pks);
1435            if pks.is_empty() {
1436                continue;
1437            }
1438
1439            let placeholders: Vec<String> =
1440                (1..=pks.len()).map(|i| dialect.placeholder(i)).collect();
1441            let sql = format!(
1442                "DELETE FROM {} WHERE {} IN ({})",
1443                dialect.quote_identifier(child_table),
1444                dialect.quote_identifier(fk_col),
1445                placeholders.join(", ")
1446            );
1447
1448            match self.connection.execute(cx, &sql, &pks).await {
1449                Outcome::Ok(_) => {}
1450                Outcome::Err(e) => {
1451                    self.pending_delete = deletes;
1452                    return Outcome::Err(e);
1453                }
1454                Outcome::Cancelled(r) => {
1455                    self.pending_delete = deletes;
1456                    return Outcome::Cancelled(r);
1457                }
1458                Outcome::Panicked(p) => {
1459                    self.pending_delete = deletes;
1460                    return Outcome::Panicked(p);
1461                }
1462            }
1463
1464            // Remove now-deleted children from the identity map to prevent stale reads.
1465            let pk_hashes: std::collections::HashSet<u64> = pks
1466                .iter()
1467                .map(|v| hash_values(std::slice::from_ref(v)))
1468                .collect();
1469            let mut to_remove: Vec<ObjectKey> = Vec::new();
1470            for (k, t) in &self.identity_map {
1471                if t.table_name != child_table {
1472                    continue;
1473                }
1474                let Some(idx) = t.column_names.iter().position(|col| *col == fk_col) else {
1475                    continue;
1476                };
1477                let fk_val = &t.values[idx];
1478                if pk_hashes.contains(&hash_values(std::slice::from_ref(fk_val))) {
1479                    to_remove.push(*k);
1480                }
1481            }
1482            for k in &to_remove {
1483                self.identity_map.remove(k);
1484            }
1485            self.pending_new.retain(|k| !to_remove.contains(k));
1486            self.pending_dirty.retain(|k| !to_remove.contains(k));
1487            self.pending_delete.retain(|k| !to_remove.contains(k));
1488        }
1489
1490        // (a2) Delete children for composite foreign keys using row-value IN.
1491        for (key, mut tuples) in cascade_child_deletes_composite {
1492            if tuples.is_empty() {
1493                continue;
1494            }
1495
1496            let mut seen: std::collections::HashSet<u64> = std::collections::HashSet::new();
1497            tuples.retain(|t| seen.insert(hash_values(t)));
1498
1499            if tuples.is_empty() {
1500                continue;
1501            }
1502
1503            let col_list = key
1504                .fk_cols
1505                .iter()
1506                .map(|c| dialect.quote_identifier(c))
1507                .collect::<Vec<_>>()
1508                .join(", ");
1509
1510            let mut params: Vec<Value> = Vec::with_capacity(tuples.len() * key.fk_cols.len());
1511            let mut idx = 1;
1512            let tuple_sql: Vec<String> = tuples
1513                .iter()
1514                .map(|t| {
1515                    for v in t {
1516                        params.push(v.clone());
1517                    }
1518                    let inner = (0..key.fk_cols.len())
1519                        .map(|_| {
1520                            let ph = dialect.placeholder(idx);
1521                            idx += 1;
1522                            ph
1523                        })
1524                        .collect::<Vec<_>>()
1525                        .join(", ");
1526                    format!("({})", inner)
1527                })
1528                .collect();
1529
1530            let sql = format!(
1531                "DELETE FROM {} WHERE ({}) IN ({})",
1532                dialect.quote_identifier(key.table),
1533                col_list,
1534                tuple_sql.join(", ")
1535            );
1536
1537            match self.connection.execute(cx, &sql, &params).await {
1538                Outcome::Ok(_) => {}
1539                Outcome::Err(e) => {
1540                    self.pending_delete = deletes;
1541                    return Outcome::Err(e);
1542                }
1543                Outcome::Cancelled(r) => {
1544                    self.pending_delete = deletes;
1545                    return Outcome::Cancelled(r);
1546                }
1547                Outcome::Panicked(p) => {
1548                    self.pending_delete = deletes;
1549                    return Outcome::Panicked(p);
1550                }
1551            }
1552
1553            // Remove now-deleted children from the identity map to prevent stale reads.
1554            let tuple_hashes: std::collections::HashSet<u64> =
1555                tuples.iter().map(|t| hash_values(t)).collect();
1556            let mut to_remove: Vec<ObjectKey> = Vec::new();
1557            for (k, t) in &self.identity_map {
1558                if t.table_name != key.table {
1559                    continue;
1560                }
1561
1562                let mut child_fk: Vec<Value> = Vec::with_capacity(key.fk_cols.len());
1563                let mut missing = false;
1564                for fk_col in &key.fk_cols {
1565                    let Some(idx) = t.column_names.iter().position(|col| col == fk_col) else {
1566                        missing = true;
1567                        break;
1568                    };
1569                    child_fk.push(t.values[idx].clone());
1570                }
1571                if missing {
1572                    continue;
1573                }
1574                if tuple_hashes.contains(&hash_values(&child_fk)) {
1575                    to_remove.push(*k);
1576                }
1577            }
1578            for k in &to_remove {
1579                self.identity_map.remove(k);
1580            }
1581            self.pending_new.retain(|k| !to_remove.contains(k));
1582            self.pending_dirty.retain(|k| !to_remove.contains(k));
1583            self.pending_delete.retain(|k| !to_remove.contains(k));
1584        }
1585
1586        // (b) Clean up link-table rows for many-to-many relationships (association rows only).
1587        for ((link_table, local_col), mut pks) in cascade_link_deletes_single {
1588            dedup_by_hash(&mut pks);
1589            if pks.is_empty() {
1590                continue;
1591            }
1592
1593            let placeholders: Vec<String> =
1594                (1..=pks.len()).map(|i| dialect.placeholder(i)).collect();
1595            let sql = format!(
1596                "DELETE FROM {} WHERE {} IN ({})",
1597                dialect.quote_identifier(link_table),
1598                dialect.quote_identifier(local_col),
1599                placeholders.join(", ")
1600            );
1601
1602            match self.connection.execute(cx, &sql, &pks).await {
1603                Outcome::Ok(_) => {}
1604                Outcome::Err(e) => {
1605                    self.pending_delete = deletes;
1606                    return Outcome::Err(e);
1607                }
1608                Outcome::Cancelled(r) => {
1609                    self.pending_delete = deletes;
1610                    return Outcome::Cancelled(r);
1611                }
1612                Outcome::Panicked(p) => {
1613                    self.pending_delete = deletes;
1614                    return Outcome::Panicked(p);
1615                }
1616            }
1617        }
1618
1619        // (b2) Clean up link-table rows for composite parent keys using row-value IN.
1620        for (key, mut tuples) in cascade_link_deletes_composite {
1621            if tuples.is_empty() {
1622                continue;
1623            }
1624
1625            let mut seen: std::collections::HashSet<u64> = std::collections::HashSet::new();
1626            tuples.retain(|t| seen.insert(hash_values(t)));
1627
1628            if tuples.is_empty() {
1629                continue;
1630            }
1631
1632            let col_list = key
1633                .fk_cols
1634                .iter()
1635                .map(|c| dialect.quote_identifier(c))
1636                .collect::<Vec<_>>()
1637                .join(", ");
1638
1639            let mut params: Vec<Value> = Vec::with_capacity(tuples.len() * key.fk_cols.len());
1640            let mut idx = 1;
1641            let tuple_sql: Vec<String> = tuples
1642                .iter()
1643                .map(|t| {
1644                    for v in t {
1645                        params.push(v.clone());
1646                    }
1647                    let inner = (0..key.fk_cols.len())
1648                        .map(|_| {
1649                            let ph = dialect.placeholder(idx);
1650                            idx += 1;
1651                            ph
1652                        })
1653                        .collect::<Vec<_>>()
1654                        .join(", ");
1655                    format!("({})", inner)
1656                })
1657                .collect();
1658
1659            let sql = format!(
1660                "DELETE FROM {} WHERE ({}) IN ({})",
1661                dialect.quote_identifier(key.table),
1662                col_list,
1663                tuple_sql.join(", ")
1664            );
1665
1666            match self.connection.execute(cx, &sql, &params).await {
1667                Outcome::Ok(_) => {}
1668                Outcome::Err(e) => {
1669                    self.pending_delete = deletes;
1670                    return Outcome::Err(e);
1671                }
1672                Outcome::Cancelled(r) => {
1673                    self.pending_delete = deletes;
1674                    return Outcome::Cancelled(r);
1675                }
1676                Outcome::Panicked(p) => {
1677                    self.pending_delete = deletes;
1678                    return Outcome::Panicked(p);
1679                }
1680            }
1681        }
1682
1683        let mut actually_deleted: Vec<ObjectKey> = Vec::new();
1684        for key in &deletes {
1685            if let Some(tracked) = self.identity_map.get(key) {
1686                // Skip if object was un-deleted (state changed from Deleted)
1687                if tracked.state != ObjectState::Deleted {
1688                    continue;
1689                }
1690
1691                // Skip objects without primary keys - cannot safely DELETE without WHERE clause
1692                if tracked.pk_columns.is_empty() || tracked.pk_values.is_empty() {
1693                    tracing::warn!(
1694                        table = tracked.table_name,
1695                        "Skipping DELETE for object without primary key - cannot identify row"
1696                    );
1697                    continue;
1698                }
1699
1700                // Copy needed metadata so we can mutate the identity map after the DB op.
1701                let pk_columns = tracked.pk_columns.clone();
1702                let pk_values = tracked.pk_values.clone();
1703                let table_name = tracked.table_name;
1704                let relationships = tracked.relationships;
1705
1706                // Build WHERE clause from primary key columns and values
1707                let where_parts: Vec<String> = pk_columns
1708                    .iter()
1709                    .enumerate()
1710                    .map(|(i, col)| {
1711                        format!(
1712                            "{} = {}",
1713                            dialect.quote_identifier(col),
1714                            dialect.placeholder(i + 1)
1715                        )
1716                    })
1717                    .collect();
1718
1719                let sql = format!(
1720                    "DELETE FROM {} WHERE {}",
1721                    dialect.quote_identifier(table_name),
1722                    where_parts.join(" AND ")
1723                );
1724
1725                match self.connection.execute(cx, &sql, &pk_values).await {
1726                    Outcome::Ok(_) => {
1727                        actually_deleted.push(*key);
1728
1729                        // PassiveDeletes::Passive orphan tracking: the DB will delete children,
1730                        // so eagerly detach them from the identity map after the parent delete succeeds.
1731                        if !pk_values.is_empty() {
1732                            let mut to_remove: Vec<ObjectKey> = Vec::new();
1733                            for rel in relationships {
1734                                if !rel.cascade_delete
1735                                    || !matches!(
1736                                        rel.passive_deletes,
1737                                        sqlmodel_core::PassiveDeletes::Passive
1738                                    )
1739                                {
1740                                    continue;
1741                                }
1742                                if !matches!(
1743                                    rel.kind,
1744                                    sqlmodel_core::RelationshipKind::OneToMany
1745                                        | sqlmodel_core::RelationshipKind::OneToOne
1746                                ) {
1747                                    continue;
1748                                }
1749
1750                                let fk_cols = rel.remote_key_cols();
1751                                if fk_cols.is_empty() || fk_cols.len() != pk_values.len() {
1752                                    continue;
1753                                }
1754
1755                                for (k, t) in &self.identity_map {
1756                                    if t.table_name != rel.related_table {
1757                                        continue;
1758                                    }
1759                                    let mut matches_parent = true;
1760                                    for (fk_col, parent_val) in fk_cols.iter().zip(&pk_values) {
1761                                        let Some(idx) =
1762                                            t.column_names.iter().position(|col| col == fk_col)
1763                                        else {
1764                                            matches_parent = false;
1765                                            break;
1766                                        };
1767                                        if &t.values[idx] != parent_val {
1768                                            matches_parent = false;
1769                                            break;
1770                                        }
1771                                    }
1772                                    if matches_parent {
1773                                        to_remove.push(*k);
1774                                    }
1775                                }
1776                            }
1777
1778                            for k in &to_remove {
1779                                self.identity_map.remove(k);
1780                            }
1781                            self.pending_new.retain(|k| !to_remove.contains(k));
1782                            self.pending_dirty.retain(|k| !to_remove.contains(k));
1783                            self.pending_delete.retain(|k| !to_remove.contains(k));
1784                        }
1785                    }
1786                    Outcome::Err(e) => {
1787                        // Only restore deletes that weren't already executed
1788                        // (exclude actually_deleted items from restoration)
1789                        self.pending_delete = deletes
1790                            .into_iter()
1791                            .filter(|k| !actually_deleted.contains(k))
1792                            .collect();
1793                        // Remove successfully deleted objects before returning error
1794                        for key in &actually_deleted {
1795                            self.identity_map.remove(key);
1796                        }
1797                        return Outcome::Err(e);
1798                    }
1799                    Outcome::Cancelled(r) => {
1800                        // Same handling for cancellation
1801                        self.pending_delete = deletes
1802                            .into_iter()
1803                            .filter(|k| !actually_deleted.contains(k))
1804                            .collect();
1805                        for key in &actually_deleted {
1806                            self.identity_map.remove(key);
1807                        }
1808                        return Outcome::Cancelled(r);
1809                    }
1810                    Outcome::Panicked(p) => {
1811                        // Same handling for panic
1812                        self.pending_delete = deletes
1813                            .into_iter()
1814                            .filter(|k| !actually_deleted.contains(k))
1815                            .collect();
1816                        for key in &actually_deleted {
1817                            self.identity_map.remove(key);
1818                        }
1819                        return Outcome::Panicked(p);
1820                    }
1821                }
1822            }
1823        }
1824
1825        // Remove only actually deleted objects from identity map
1826        for key in &actually_deleted {
1827            self.identity_map.remove(key);
1828        }
1829
1830        // 2. Execute INSERTs
1831        let inserts: Vec<ObjectKey> = std::mem::take(&mut self.pending_new);
1832        for key in &inserts {
1833            if let Some(tracked) = self.identity_map.get_mut(key) {
1834                // Skip if already persistent (was inserted in a previous attempt before error)
1835                if tracked.state == ObjectState::Persistent {
1836                    continue;
1837                }
1838
1839                // Build INSERT statement using stored column names and values
1840                let columns = &tracked.column_names;
1841                let columns_sql: Vec<String> = columns
1842                    .iter()
1843                    .map(|c| dialect.quote_identifier(c))
1844                    .collect();
1845                let placeholders: Vec<String> = (1..=columns.len())
1846                    .map(|i| dialect.placeholder(i))
1847                    .collect();
1848
1849                let sql = format!(
1850                    "INSERT INTO {} ({}) VALUES ({})",
1851                    dialect.quote_identifier(tracked.table_name),
1852                    columns_sql.join(", "),
1853                    placeholders.join(", ")
1854                );
1855
1856                match self.connection.execute(cx, &sql, &tracked.values).await {
1857                    Outcome::Ok(_) => {
1858                        tracked.state = ObjectState::Persistent;
1859                        // Set original_state for future dirty checking (serialize current values)
1860                        tracked.original_state =
1861                            Some(serde_json::to_vec(&tracked.values).unwrap_or_default());
1862                    }
1863                    Outcome::Err(e) => {
1864                        // Restore pending_new for retry
1865                        self.pending_new = inserts;
1866                        return Outcome::Err(e);
1867                    }
1868                    Outcome::Cancelled(r) => {
1869                        // Restore pending_new for retry (same as Err handling)
1870                        self.pending_new = inserts;
1871                        return Outcome::Cancelled(r);
1872                    }
1873                    Outcome::Panicked(p) => {
1874                        // Restore pending_new for retry (same as Err handling)
1875                        self.pending_new = inserts;
1876                        return Outcome::Panicked(p);
1877                    }
1878                }
1879            }
1880        }
1881
1882        // 3. Execute UPDATEs for dirty objects
1883        let dirty: Vec<ObjectKey> = std::mem::take(&mut self.pending_dirty);
1884        for key in &dirty {
1885            if let Some(tracked) = self.identity_map.get_mut(key) {
1886                // Only UPDATE persistent objects
1887                if tracked.state != ObjectState::Persistent {
1888                    continue;
1889                }
1890
1891                // Skip objects without primary keys - cannot safely UPDATE without WHERE clause
1892                if tracked.pk_columns.is_empty() || tracked.pk_values.is_empty() {
1893                    tracing::warn!(
1894                        table = tracked.table_name,
1895                        "Skipping UPDATE for object without primary key - cannot identify row"
1896                    );
1897                    continue;
1898                }
1899
1900                // Check if actually dirty by comparing serialized state
1901                let current_state = serde_json::to_vec(&tracked.values).unwrap_or_default();
1902                let is_dirty = tracked.original_state.as_ref() != Some(&current_state);
1903
1904                if !is_dirty {
1905                    continue;
1906                }
1907
1908                // Build UPDATE statement with all non-PK columns
1909                let mut set_parts = Vec::new();
1910                let mut params = Vec::new();
1911                let mut param_idx = 1;
1912
1913                for (i, col) in tracked.column_names.iter().enumerate() {
1914                    // Skip primary key columns in SET clause
1915                    if !tracked.pk_columns.contains(col) {
1916                        set_parts.push(format!(
1917                            "{} = {}",
1918                            dialect.quote_identifier(col),
1919                            dialect.placeholder(param_idx)
1920                        ));
1921                        params.push(tracked.values[i].clone());
1922                        param_idx += 1;
1923                    }
1924                }
1925
1926                // Add WHERE clause for primary key
1927                let where_parts: Vec<String> = tracked
1928                    .pk_columns
1929                    .iter()
1930                    .map(|col| {
1931                        let clause = format!(
1932                            "{} = {}",
1933                            dialect.quote_identifier(col),
1934                            dialect.placeholder(param_idx)
1935                        );
1936                        param_idx += 1;
1937                        clause
1938                    })
1939                    .collect();
1940
1941                // Add PK values to params
1942                params.extend(tracked.pk_values.clone());
1943
1944                if set_parts.is_empty() {
1945                    continue; // No non-PK columns to update
1946                }
1947
1948                let sql = format!(
1949                    "UPDATE {} SET {} WHERE {}",
1950                    dialect.quote_identifier(tracked.table_name),
1951                    set_parts.join(", "),
1952                    where_parts.join(" AND ")
1953                );
1954
1955                match self.connection.execute(cx, &sql, &params).await {
1956                    Outcome::Ok(_) => {
1957                        // Update original_state to current state
1958                        tracked.original_state = Some(current_state);
1959                    }
1960                    Outcome::Err(e) => {
1961                        // Restore pending_dirty for retry
1962                        self.pending_dirty = dirty;
1963                        return Outcome::Err(e);
1964                    }
1965                    Outcome::Cancelled(r) => {
1966                        // Restore pending_dirty for retry (same as Err handling)
1967                        self.pending_dirty = dirty;
1968                        return Outcome::Cancelled(r);
1969                    }
1970                    Outcome::Panicked(p) => {
1971                        // Restore pending_dirty for retry (same as Err handling)
1972                        self.pending_dirty = dirty;
1973                        return Outcome::Panicked(p);
1974                    }
1975                }
1976            }
1977        }
1978
1979        // Fire after_flush event
1980        if let Err(e) = self.event_callbacks.fire(SessionEvent::AfterFlush) {
1981            return Outcome::Err(e);
1982        }
1983
1984        Outcome::Ok(())
1985    }
1986
1987    /// Commit the current transaction.
1988    pub async fn commit(&mut self, cx: &Cx) -> Outcome<(), Error> {
1989        // Flush any pending changes first
1990        match self.flush(cx).await {
1991            Outcome::Ok(()) => {}
1992            Outcome::Err(e) => return Outcome::Err(e),
1993            Outcome::Cancelled(r) => return Outcome::Cancelled(r),
1994            Outcome::Panicked(p) => return Outcome::Panicked(p),
1995        }
1996
1997        // Fire before_commit event (can abort)
1998        if let Err(e) = self.event_callbacks.fire(SessionEvent::BeforeCommit) {
1999            return Outcome::Err(e);
2000        }
2001
2002        if self.in_transaction {
2003            match self.connection.execute(cx, "COMMIT", &[]).await {
2004                Outcome::Ok(_) => {
2005                    self.in_transaction = false;
2006                }
2007                Outcome::Err(e) => return Outcome::Err(e),
2008                Outcome::Cancelled(r) => return Outcome::Cancelled(r),
2009                Outcome::Panicked(p) => return Outcome::Panicked(p),
2010            }
2011        }
2012
2013        // Expire objects if configured
2014        if self.config.expire_on_commit {
2015            for tracked in self.identity_map.values_mut() {
2016                if tracked.state == ObjectState::Persistent {
2017                    tracked.state = ObjectState::Expired;
2018                }
2019            }
2020        }
2021
2022        // Fire after_commit event
2023        if let Err(e) = self.event_callbacks.fire(SessionEvent::AfterCommit) {
2024            return Outcome::Err(e);
2025        }
2026
2027        Outcome::Ok(())
2028    }
2029
2030    /// Rollback the current transaction.
2031    pub async fn rollback(&mut self, cx: &Cx) -> Outcome<(), Error> {
2032        if self.in_transaction {
2033            match self.connection.execute(cx, "ROLLBACK", &[]).await {
2034                Outcome::Ok(_) => {
2035                    self.in_transaction = false;
2036                }
2037                Outcome::Err(e) => return Outcome::Err(e),
2038                Outcome::Cancelled(r) => return Outcome::Cancelled(r),
2039                Outcome::Panicked(p) => return Outcome::Panicked(p),
2040            }
2041        }
2042
2043        // Clear pending operations
2044        self.pending_new.clear();
2045        self.pending_delete.clear();
2046        self.pending_dirty.clear();
2047
2048        // Revert objects to original state or remove new ones
2049        let mut to_remove = Vec::new();
2050        for (key, tracked) in &mut self.identity_map {
2051            match tracked.state {
2052                ObjectState::New => {
2053                    to_remove.push(*key);
2054                }
2055                ObjectState::Deleted => {
2056                    tracked.state = ObjectState::Persistent;
2057                }
2058                _ => {}
2059            }
2060        }
2061
2062        for key in to_remove {
2063            self.identity_map.remove(&key);
2064        }
2065
2066        // Fire after_rollback event
2067        if let Err(e) = self.event_callbacks.fire(SessionEvent::AfterRollback) {
2068            return Outcome::Err(e);
2069        }
2070
2071        Outcome::Ok(())
2072    }
2073
2074    // ========================================================================
2075    // Lazy Loading
2076    // ========================================================================
2077
2078    /// Load a single lazy relationship.
2079    ///
2080    /// Fetches the related object from the database and caches it in the Lazy wrapper.
2081    /// If the relationship has already been loaded, returns the cached value.
2082    ///
2083    /// # Example
2084    ///
2085    /// ```ignore
2086    /// session.load_lazy(&hero.team, &cx).await?;
2087    /// let team = hero.team.get(); // Now available
2088    /// ```
2089    #[tracing::instrument(level = "debug", skip(self, lazy, cx))]
2090    pub async fn load_lazy<
2091        T: Model + Clone + Send + Sync + Serialize + for<'de> Deserialize<'de> + 'static,
2092    >(
2093        &mut self,
2094        lazy: &Lazy<T>,
2095        cx: &Cx,
2096    ) -> Outcome<bool, Error> {
2097        tracing::debug!(
2098            model = std::any::type_name::<T>(),
2099            fk = ?lazy.fk(),
2100            already_loaded = lazy.is_loaded(),
2101            "Loading lazy relationship"
2102        );
2103
2104        // If already loaded, return success
2105        if lazy.is_loaded() {
2106            tracing::trace!("Already loaded");
2107            return Outcome::Ok(lazy.get().is_some());
2108        }
2109
2110        // If no FK, set as empty and return
2111        let Some(fk) = lazy.fk() else {
2112            let _ = lazy.set_loaded(None);
2113            return Outcome::Ok(false);
2114        };
2115
2116        // Fetch from database using get()
2117        let obj = match self.get::<T>(cx, fk.clone()).await {
2118            Outcome::Ok(obj) => obj,
2119            Outcome::Err(e) => return Outcome::Err(e),
2120            Outcome::Cancelled(r) => return Outcome::Cancelled(r),
2121            Outcome::Panicked(p) => return Outcome::Panicked(p),
2122        };
2123
2124        let found = obj.is_some();
2125
2126        // Cache the result
2127        let _ = lazy.set_loaded(obj);
2128
2129        tracing::debug!(found = found, "Lazy load complete");
2130
2131        Outcome::Ok(found)
2132    }
2133
2134    /// Batch load lazy relationships for multiple objects.
2135    ///
2136    /// This method collects all FK values, executes a single query, and populates
2137    /// each Lazy field. This prevents the N+1 query problem when iterating over
2138    /// a collection and accessing lazy relationships.
2139    ///
2140    /// # Example
2141    ///
2142    /// ```ignore
2143    /// // Load 100 heroes
2144    /// let mut heroes = session.query::<Hero>().all().await?;
2145    ///
2146    /// // Without batch loading: 100 queries (N+1 problem)
2147    /// // With batch loading: 1 query
2148    /// session.load_many(&cx, &mut heroes, |h| &h.team).await?;
2149    ///
2150    /// // All teams now loaded
2151    /// for hero in &heroes {
2152    ///     if let Some(team) = hero.team.get() {
2153    ///         println!("{} is on {}", hero.name, team.name);
2154    ///     }
2155    /// }
2156    /// ```
2157    #[tracing::instrument(level = "debug", skip(self, cx, objects, accessor))]
2158    pub async fn load_many<P, T, F>(
2159        &mut self,
2160        cx: &Cx,
2161        objects: &[P],
2162        accessor: F,
2163    ) -> Outcome<usize, Error>
2164    where
2165        P: Model + 'static,
2166        T: Model + Clone + Send + Sync + Serialize + for<'de> Deserialize<'de> + 'static,
2167        F: Fn(&P) -> &Lazy<T>,
2168    {
2169        // Collect all FK values that need loading
2170        let mut fk_values: Vec<Value> = Vec::new();
2171        let mut fk_indices: Vec<usize> = Vec::new();
2172
2173        for (idx, obj) in objects.iter().enumerate() {
2174            let lazy = accessor(obj);
2175            if !lazy.is_loaded()
2176                && !lazy.is_empty()
2177                && let Some(fk) = lazy.fk()
2178            {
2179                fk_values.push(fk.clone());
2180                fk_indices.push(idx);
2181            }
2182        }
2183
2184        let fk_count = fk_values.len();
2185        tracing::info!(
2186            parent_model = std::any::type_name::<P>(),
2187            related_model = std::any::type_name::<T>(),
2188            parent_count = objects.len(),
2189            fk_count = fk_count,
2190            "Batch loading lazy relationships"
2191        );
2192
2193        if fk_values.is_empty() {
2194            // Nothing to load - mark all empty/loaded Lazy fields
2195            for obj in objects {
2196                let lazy = accessor(obj);
2197                if !lazy.is_loaded() && lazy.is_empty() {
2198                    let _ = lazy.set_loaded(None);
2199                }
2200            }
2201            return Outcome::Ok(0);
2202        }
2203
2204        // Build query with IN clause (dialect-correct placeholders/quoting).
2205        let dialect = self.connection.dialect();
2206        let pk_col = T::PRIMARY_KEY.first().unwrap_or(&"id");
2207        let placeholders: Vec<String> = (1..=fk_values.len())
2208            .map(|i| dialect.placeholder(i))
2209            .collect();
2210        let sql = format!(
2211            "SELECT * FROM {} WHERE {} IN ({})",
2212            dialect.quote_identifier(T::TABLE_NAME),
2213            dialect.quote_identifier(pk_col),
2214            placeholders.join(", ")
2215        );
2216
2217        let rows = match self.connection.query(cx, &sql, &fk_values).await {
2218            Outcome::Ok(rows) => rows,
2219            Outcome::Err(e) => return Outcome::Err(e),
2220            Outcome::Cancelled(r) => return Outcome::Cancelled(r),
2221            Outcome::Panicked(p) => return Outcome::Panicked(p),
2222        };
2223
2224        // Convert rows to objects and build PK hash -> object lookup
2225        let mut lookup: HashMap<u64, T> = HashMap::new();
2226        for row in &rows {
2227            match T::from_row(row) {
2228                Ok(obj) => {
2229                    let pk_values = obj.primary_key_value();
2230                    let pk_hash = hash_values(&pk_values);
2231
2232                    // Add to session identity map
2233                    let key = ObjectKey::from_pk::<T>(&pk_values);
2234
2235                    // Extract column data from the model while we have the concrete type
2236                    let row_data = obj.to_row();
2237                    let column_names: Vec<&'static str> =
2238                        row_data.iter().map(|(name, _)| *name).collect();
2239                    let values: Vec<Value> = row_data.into_iter().map(|(_, v)| v).collect();
2240
2241                    // Serialize values for dirty checking (must match format used in flush)
2242                    let serialized = serde_json::to_vec(&values).ok();
2243
2244                    let tracked = TrackedObject {
2245                        object: Box::new(obj.clone()),
2246                        original_state: serialized,
2247                        state: ObjectState::Persistent,
2248                        table_name: T::TABLE_NAME,
2249                        column_names,
2250                        values,
2251                        pk_columns: T::PRIMARY_KEY.to_vec(),
2252                        pk_values: pk_values.clone(),
2253                        relationships: T::RELATIONSHIPS,
2254                        expired_attributes: None,
2255                    };
2256                    self.identity_map.insert(key, tracked);
2257
2258                    // Add to lookup
2259                    lookup.insert(pk_hash, obj);
2260                }
2261                Err(_) => continue,
2262            }
2263        }
2264
2265        // Populate each Lazy field
2266        let mut loaded_count = 0;
2267        for obj in objects {
2268            let lazy = accessor(obj);
2269            if !lazy.is_loaded() {
2270                if let Some(fk) = lazy.fk() {
2271                    let fk_hash = hash_values(std::slice::from_ref(fk));
2272                    let related = lookup.get(&fk_hash).cloned();
2273                    let found = related.is_some();
2274                    let _ = lazy.set_loaded(related);
2275                    if found {
2276                        loaded_count += 1;
2277                    }
2278                } else {
2279                    let _ = lazy.set_loaded(None);
2280                }
2281            }
2282        }
2283
2284        tracing::debug!(
2285            query_count = 1,
2286            loaded_count = loaded_count,
2287            "Batch load complete"
2288        );
2289
2290        Outcome::Ok(loaded_count)
2291    }
2292
2293    /// Batch load many-to-many relationships for multiple parent objects.
2294    ///
2295    /// This method loads related objects via a link table in a single query,
2296    /// avoiding the N+1 problem for many-to-many relationships.
2297    ///
2298    /// # Example
2299    ///
2300    /// ```ignore
2301    /// // Load 100 heroes
2302    /// let mut heroes = session.query::<Hero>().all().await?;
2303    ///
2304    /// // Without batch loading: 100 queries (N+1 problem)
2305    /// // With batch loading: 1 query via JOIN
2306    /// let link_info = LinkTableInfo::new("hero_powers", "hero_id", "power_id");
2307    /// session.load_many_to_many(&cx, &mut heroes, |h| &mut h.powers, |h| h.id.unwrap(), &link_info).await?;
2308    ///
2309    /// // All powers now loaded
2310    /// for hero in &heroes {
2311    ///     if let Some(powers) = hero.powers.get() {
2312    ///         println!("{} has {} powers", hero.name, powers.len());
2313    ///     }
2314    /// }
2315    /// ```
2316    #[tracing::instrument(level = "debug", skip(self, cx, objects, accessor, parent_pk))]
2317    pub async fn load_many_to_many<P, Child, FA, FP>(
2318        &mut self,
2319        cx: &Cx,
2320        objects: &mut [P],
2321        accessor: FA,
2322        parent_pk: FP,
2323        link_table: &sqlmodel_core::LinkTableInfo,
2324    ) -> Outcome<usize, Error>
2325    where
2326        P: Model + 'static,
2327        Child: Model + Clone + Send + Sync + Serialize + for<'de> Deserialize<'de> + 'static,
2328        FA: Fn(&mut P) -> &mut sqlmodel_core::RelatedMany<Child>,
2329        FP: Fn(&P) -> Value,
2330    {
2331        self.load_many_to_many_pk(cx, objects, accessor, |p| vec![parent_pk(p)], link_table)
2332            .await
2333    }
2334
2335    /// Batch load many-to-many relationships for multiple parent objects using composite keys.
2336    ///
2337    /// This is the generalized form of `load_many_to_many` that supports composite parent and/or
2338    /// child primary keys via `LinkTableInfo::composite(...)`.
2339    #[tracing::instrument(level = "debug", skip(self, cx, objects, accessor, parent_pk))]
2340    pub async fn load_many_to_many_pk<P, Child, FA, FP>(
2341        &mut self,
2342        cx: &Cx,
2343        objects: &mut [P],
2344        accessor: FA,
2345        parent_pk: FP,
2346        link_table: &sqlmodel_core::LinkTableInfo,
2347    ) -> Outcome<usize, Error>
2348    where
2349        P: Model + 'static,
2350        Child: Model + Clone + Send + Sync + Serialize + for<'de> Deserialize<'de> + 'static,
2351        FA: Fn(&mut P) -> &mut sqlmodel_core::RelatedMany<Child>,
2352        FP: Fn(&P) -> Vec<Value>,
2353    {
2354        // Collect all parent PK tuples.
2355        let mut pk_tuples: Vec<Vec<Value>> = Vec::with_capacity(objects.len());
2356        let mut pk_by_index: Vec<(usize, Vec<Value>)> = Vec::new();
2357        for (idx, obj) in objects.iter().enumerate() {
2358            let pk = parent_pk(obj);
2359            pk_tuples.push(pk.clone());
2360            pk_by_index.push((idx, pk));
2361        }
2362
2363        tracing::info!(
2364            parent_model = std::any::type_name::<P>(),
2365            related_model = std::any::type_name::<Child>(),
2366            parent_count = pk_tuples.len(),
2367            link_table = link_table.table_name,
2368            "Batch loading many-to-many relationships"
2369        );
2370
2371        if pk_tuples.is_empty() {
2372            return Outcome::Ok(0);
2373        }
2374
2375        // Build query with JOIN through link table (dialect-correct placeholders/quoting):
2376        // SELECT child.*, link.<local_cols...> as __parent_pk{N}
2377        // FROM child
2378        // JOIN link ON child.<pk_cols...> = link.<remote_cols...>
2379        // WHERE link.<local_cols...> IN (...)
2380        let dialect = self.connection.dialect();
2381        let local_cols = link_table.local_cols();
2382        let remote_cols = link_table.remote_cols();
2383        if local_cols.is_empty() || remote_cols.is_empty() {
2384            return Outcome::Err(Error::Custom(
2385                "link_table must specify local/remote columns".to_string(),
2386            ));
2387        }
2388        if remote_cols.len() != Child::PRIMARY_KEY.len() {
2389            return Outcome::Err(Error::Custom(format!(
2390                "link_table remote cols count ({}) must match child PRIMARY_KEY len ({})",
2391                remote_cols.len(),
2392                Child::PRIMARY_KEY.len()
2393            )));
2394        }
2395
2396        let child_table = dialect.quote_identifier(Child::TABLE_NAME);
2397        let link_table_q = dialect.quote_identifier(link_table.table_name);
2398
2399        let parent_select_parts: String = local_cols
2400            .iter()
2401            .enumerate()
2402            .map(|(i, col)| {
2403                format!(
2404                    "{link_table_q}.{} AS __parent_pk{}",
2405                    dialect.quote_identifier(col),
2406                    i
2407                )
2408            })
2409            .collect::<Vec<_>>()
2410            .join(", ");
2411
2412        let join_parts: String = remote_cols
2413            .iter()
2414            .zip(Child::PRIMARY_KEY.iter().copied())
2415            .map(|(link_col, child_col)| {
2416                format!(
2417                    "{child_table}.{} = {link_table_q}.{}",
2418                    dialect.quote_identifier(child_col),
2419                    dialect.quote_identifier(link_col)
2420                )
2421            })
2422            .collect::<Vec<_>>()
2423            .join(" AND ");
2424
2425        let (where_sql, params) = if local_cols.len() == 1 {
2426            let mut params: Vec<Value> = Vec::with_capacity(pk_tuples.len());
2427            for t in &pk_tuples {
2428                if let Some(v) = t.first() {
2429                    params.push(v.clone());
2430                }
2431            }
2432            let placeholders: Vec<String> =
2433                (1..=params.len()).map(|i| dialect.placeholder(i)).collect();
2434            let where_sql = format!(
2435                "{link_table_q}.{} IN ({})",
2436                dialect.quote_identifier(local_cols[0]),
2437                placeholders.join(", ")
2438            );
2439            (where_sql, params)
2440        } else {
2441            let mut tuples: Vec<Vec<Value>> = Vec::with_capacity(pk_tuples.len());
2442            for t in &pk_tuples {
2443                if t.len() == local_cols.len() {
2444                    tuples.push(t.clone());
2445                }
2446            }
2447
2448            let mut params: Vec<Value> = Vec::with_capacity(tuples.len() * local_cols.len());
2449            let mut idx = 1;
2450            let tuple_sql: Vec<String> = tuples
2451                .iter()
2452                .map(|t| {
2453                    for v in t {
2454                        params.push(v.clone());
2455                    }
2456                    let inner = (0..local_cols.len())
2457                        .map(|_| {
2458                            let ph = dialect.placeholder(idx);
2459                            idx += 1;
2460                            ph
2461                        })
2462                        .collect::<Vec<_>>()
2463                        .join(", ");
2464                    format!("({})", inner)
2465                })
2466                .collect();
2467
2468            let col_list = local_cols
2469                .iter()
2470                .map(|c| format!("{link_table_q}.{}", dialect.quote_identifier(c)))
2471                .collect::<Vec<_>>()
2472                .join(", ");
2473
2474            let where_sql = format!("({}) IN ({})", col_list, tuple_sql.join(", "));
2475            (where_sql, params)
2476        };
2477
2478        let sql = format!(
2479            "SELECT {child_table}.*, {parent_select_parts} FROM {child_table} \
2480             JOIN {link_table_q} ON {join_parts} \
2481             WHERE {where_sql}"
2482        );
2483
2484        tracing::trace!(sql = %sql, "Many-to-many batch SQL");
2485
2486        let rows = match self.connection.query(cx, &sql, &params).await {
2487            Outcome::Ok(rows) => rows,
2488            Outcome::Err(e) => return Outcome::Err(e),
2489            Outcome::Cancelled(r) => return Outcome::Cancelled(r),
2490            Outcome::Panicked(p) => return Outcome::Panicked(p),
2491        };
2492
2493        // Group children by parent PK
2494        let mut by_parent: HashMap<u64, Vec<Child>> = HashMap::new();
2495        for row in &rows {
2496            // Extract the parent PK tuple from the __parent_pk{N} aliases.
2497            let mut parent_tuple: Vec<Value> = Vec::with_capacity(local_cols.len());
2498            let mut missing = false;
2499            for i in 0..local_cols.len() {
2500                let col = format!("__parent_pk{}", i);
2501                let Some(v) = row.get_by_name(&col) else {
2502                    missing = true;
2503                    break;
2504                };
2505                parent_tuple.push(v.clone());
2506            }
2507            if missing {
2508                continue;
2509            }
2510            let parent_pk_hash = hash_values(&parent_tuple);
2511
2512            // Parse the child model
2513            match Child::from_row(row) {
2514                Ok(child) => {
2515                    by_parent.entry(parent_pk_hash).or_default().push(child);
2516                }
2517                Err(_) => continue,
2518            }
2519        }
2520
2521        // Populate each RelatedMany field
2522        let mut loaded_count = 0;
2523        for (idx, pk_tuple) in pk_by_index {
2524            let pk_hash = hash_values(&pk_tuple);
2525            // Don't `remove()` here: callers might pass the same parent more than once.
2526            let children = by_parent.get(&pk_hash).cloned().unwrap_or_default();
2527            let child_count = children.len();
2528
2529            let related = accessor(&mut objects[idx]);
2530            if pk_tuple.len() == 1 {
2531                related.set_parent_pk(pk_tuple[0].clone());
2532            } else {
2533                related.set_parent_pk(Value::Array(pk_tuple.clone()));
2534            }
2535            let _ = related.set_loaded(children);
2536            loaded_count += child_count;
2537        }
2538
2539        tracing::debug!(
2540            query_count = 1,
2541            total_children = loaded_count,
2542            "Many-to-many batch load complete"
2543        );
2544
2545        Outcome::Ok(loaded_count)
2546    }
2547
2548    /// Batch load one-to-many relationships for multiple parent objects.
2549    ///
2550    /// This populates `RelatedMany<Child>` where the child table has a foreign key column pointing
2551    /// back to the parent. It runs a single query:
2552    ///
2553    /// `SELECT *, <fk_col> AS __parent_pk FROM <child_table> WHERE <fk_col> IN (...)`
2554    ///
2555    /// and then groups results per parent PK to populate each `RelatedMany`.
2556    #[tracing::instrument(level = "debug", skip(self, cx, objects, accessor, parent_pk))]
2557    pub async fn load_one_to_many<P, Child, FA, FP>(
2558        &mut self,
2559        cx: &Cx,
2560        objects: &mut [P],
2561        accessor: FA,
2562        parent_pk: FP,
2563    ) -> Outcome<usize, Error>
2564    where
2565        P: Model + 'static,
2566        Child: Model + Clone + Send + Sync + Serialize + for<'de> Deserialize<'de> + 'static,
2567        FA: Fn(&mut P) -> &mut sqlmodel_core::RelatedMany<Child>,
2568        FP: Fn(&P) -> Value,
2569    {
2570        // Collect parent PKs for objects that still need loading.
2571        let mut pks: Vec<Value> = Vec::new();
2572        let mut pk_by_index: Vec<(usize, Value)> = Vec::new();
2573        for (idx, obj) in objects.iter_mut().enumerate() {
2574            let pk = parent_pk(&*obj);
2575            let related = accessor(obj);
2576            if related.is_loaded() {
2577                continue;
2578            }
2579
2580            related.set_parent_pk(pk.clone());
2581
2582            if matches!(pk, Value::Null) {
2583                // Unsaved parent: empty collection, mark loaded.
2584                let _ = related.set_loaded(Vec::new());
2585                continue;
2586            }
2587
2588            pks.push(pk.clone());
2589            pk_by_index.push((idx, pk));
2590        }
2591
2592        tracing::info!(
2593            parent_model = std::any::type_name::<P>(),
2594            related_model = std::any::type_name::<Child>(),
2595            parent_count = objects.len(),
2596            query_parent_count = pks.len(),
2597            "Batch loading one-to-many relationships"
2598        );
2599
2600        if pks.is_empty() {
2601            return Outcome::Ok(0);
2602        }
2603
2604        // Use the FK column from the RelatedMany field on the first object.
2605        let fk_column = accessor(&mut objects[pk_by_index[0].0]).fk_column();
2606        let dialect = self.connection.dialect();
2607        let placeholders: Vec<String> = (1..=pks.len()).map(|i| dialect.placeholder(i)).collect();
2608        let child_table = dialect.quote_identifier(Child::TABLE_NAME);
2609        let fk_q = dialect.quote_identifier(fk_column);
2610        let sql = format!(
2611            "SELECT *, {fk_q} AS __parent_pk FROM {child_table} WHERE {fk_q} IN ({})",
2612            placeholders.join(", ")
2613        );
2614
2615        tracing::trace!(sql = %sql, "One-to-many batch SQL");
2616
2617        let rows = match self.connection.query(cx, &sql, &pks).await {
2618            Outcome::Ok(rows) => rows,
2619            Outcome::Err(e) => return Outcome::Err(e),
2620            Outcome::Cancelled(r) => return Outcome::Cancelled(r),
2621            Outcome::Panicked(p) => return Outcome::Panicked(p),
2622        };
2623
2624        // Group by parent PK
2625        let mut by_parent: HashMap<u64, Vec<Child>> = HashMap::new();
2626        for row in &rows {
2627            let parent_pk_value: Value = match row.get_by_name("__parent_pk") {
2628                Some(v) => v.clone(),
2629                None => continue,
2630            };
2631            let parent_pk_hash = hash_values(std::slice::from_ref(&parent_pk_value));
2632            match Child::from_row(row) {
2633                Ok(child) => {
2634                    // Add to session identity map so later `get()` calls can reuse loaded instances.
2635                    let pk_values = child.primary_key_value();
2636                    let key = ObjectKey::from_pk::<Child>(&pk_values);
2637
2638                    self.identity_map.entry(key).or_insert_with(|| {
2639                        // Extract column data from the model while we have the concrete type
2640                        let row_data = child.to_row();
2641                        let column_names: Vec<&'static str> =
2642                            row_data.iter().map(|(name, _)| *name).collect();
2643                        let values: Vec<Value> = row_data.into_iter().map(|(_, v)| v).collect();
2644
2645                        // Serialize values for dirty checking (must match format used in flush)
2646                        let serialized = serde_json::to_vec(&values).ok();
2647
2648                        TrackedObject {
2649                            object: Box::new(child.clone()),
2650                            original_state: serialized,
2651                            state: ObjectState::Persistent,
2652                            table_name: Child::TABLE_NAME,
2653                            column_names,
2654                            values,
2655                            pk_columns: Child::PRIMARY_KEY.to_vec(),
2656                            pk_values: pk_values.clone(),
2657                            relationships: Child::RELATIONSHIPS,
2658                            expired_attributes: None,
2659                        }
2660                    });
2661
2662                    by_parent.entry(parent_pk_hash).or_default().push(child);
2663                }
2664                Err(_) => continue,
2665            }
2666        }
2667
2668        // Populate each RelatedMany.
2669        let mut loaded_count = 0;
2670        for (idx, pk) in pk_by_index {
2671            let pk_hash = hash_values(std::slice::from_ref(&pk));
2672            // Don't `remove()` here: callers might pass the same parent more than once.
2673            let children = by_parent.get(&pk_hash).cloned().unwrap_or_default();
2674            loaded_count += children.len();
2675
2676            let related = accessor(&mut objects[idx]);
2677            let _ = related.set_loaded(children);
2678        }
2679
2680        Outcome::Ok(loaded_count)
2681    }
2682
2683    /// Flush pending link/unlink operations for many-to-many relationships.
2684    ///
2685    /// This method persists pending link and unlink operations that were tracked
2686    /// via `RelatedMany::link()` and `RelatedMany::unlink()` calls.
2687    ///
2688    /// # Example
2689    ///
2690    /// ```ignore
2691    /// // Add a power to a hero
2692    /// hero.powers.link(&fly_power);
2693    ///
2694    /// // Remove a power from a hero
2695    /// hero.powers.unlink(&x_ray_vision);
2696    ///
2697    /// // Flush the link table operations
2698    /// let link_info = LinkTableInfo::new("hero_powers", "hero_id", "power_id");
2699    /// session.flush_related_many(&cx, &mut [hero], |h| &mut h.powers, |h| h.id.unwrap(), &link_info).await?;
2700    /// ```
2701    #[tracing::instrument(level = "debug", skip(self, cx, objects, accessor, parent_pk))]
2702    pub async fn flush_related_many<P, Child, FA, FP>(
2703        &mut self,
2704        cx: &Cx,
2705        objects: &mut [P],
2706        accessor: FA,
2707        parent_pk: FP,
2708        link_table: &sqlmodel_core::LinkTableInfo,
2709    ) -> Outcome<usize, Error>
2710    where
2711        P: Model + 'static,
2712        Child: Model + 'static,
2713        FA: Fn(&mut P) -> &mut sqlmodel_core::RelatedMany<Child>,
2714        FP: Fn(&P) -> Value,
2715    {
2716        self.flush_related_many_pk(cx, objects, accessor, |p| vec![parent_pk(p)], link_table)
2717            .await
2718    }
2719
2720    /// Flush pending link/unlink operations for many-to-many relationships (composite keys).
2721    #[tracing::instrument(level = "debug", skip(self, cx, objects, accessor, parent_pk))]
2722    pub async fn flush_related_many_pk<P, Child, FA, FP>(
2723        &mut self,
2724        cx: &Cx,
2725        objects: &mut [P],
2726        accessor: FA,
2727        parent_pk: FP,
2728        link_table: &sqlmodel_core::LinkTableInfo,
2729    ) -> Outcome<usize, Error>
2730    where
2731        P: Model + 'static,
2732        Child: Model + 'static,
2733        FA: Fn(&mut P) -> &mut sqlmodel_core::RelatedMany<Child>,
2734        FP: Fn(&P) -> Vec<Value>,
2735    {
2736        let mut ops = Vec::new();
2737        let local_cols = link_table.local_cols();
2738        let remote_cols = link_table.remote_cols();
2739        if local_cols.is_empty() || remote_cols.is_empty() {
2740            return Outcome::Err(Error::Custom(
2741                "link_table must specify local/remote columns".to_string(),
2742            ));
2743        }
2744
2745        // Collect pending operations from all objects
2746        for obj in objects.iter_mut() {
2747            let parent_pk_values = parent_pk(obj);
2748            if parent_pk_values.len() != local_cols.len() {
2749                return Outcome::Err(Error::Custom(format!(
2750                    "parent_pk len ({}) must match link_table local cols len ({})",
2751                    parent_pk_values.len(),
2752                    local_cols.len()
2753                )));
2754            }
2755            let related = accessor(obj);
2756
2757            // Collect pending links
2758            for child_pk_values in related.take_pending_links() {
2759                if child_pk_values.len() != remote_cols.len() {
2760                    return Outcome::Err(Error::Custom(format!(
2761                        "child pk len ({}) must match link_table remote cols len ({})",
2762                        child_pk_values.len(),
2763                        remote_cols.len()
2764                    )));
2765                }
2766                ops.push(LinkTableOp::link_multi(
2767                    link_table.table_name.to_string(),
2768                    local_cols.iter().map(|c| (*c).to_string()).collect(),
2769                    parent_pk_values.clone(),
2770                    remote_cols.iter().map(|c| (*c).to_string()).collect(),
2771                    child_pk_values,
2772                ));
2773            }
2774
2775            // Collect pending unlinks
2776            for child_pk_values in related.take_pending_unlinks() {
2777                if child_pk_values.len() != remote_cols.len() {
2778                    return Outcome::Err(Error::Custom(format!(
2779                        "child pk len ({}) must match link_table remote cols len ({})",
2780                        child_pk_values.len(),
2781                        remote_cols.len()
2782                    )));
2783                }
2784                ops.push(LinkTableOp::unlink_multi(
2785                    link_table.table_name.to_string(),
2786                    local_cols.iter().map(|c| (*c).to_string()).collect(),
2787                    parent_pk_values.clone(),
2788                    remote_cols.iter().map(|c| (*c).to_string()).collect(),
2789                    child_pk_values,
2790                ));
2791            }
2792        }
2793
2794        if ops.is_empty() {
2795            return Outcome::Ok(0);
2796        }
2797
2798        tracing::info!(
2799            parent_model = std::any::type_name::<P>(),
2800            related_model = std::any::type_name::<Child>(),
2801            link_count = ops
2802                .iter()
2803                .filter(|o| matches!(o, LinkTableOp::Link { .. }))
2804                .count(),
2805            unlink_count = ops
2806                .iter()
2807                .filter(|o| matches!(o, LinkTableOp::Unlink { .. }))
2808                .count(),
2809            link_table = link_table.table_name,
2810            "Flushing many-to-many relationship changes"
2811        );
2812
2813        flush::execute_link_table_ops(cx, &self.connection, &ops).await
2814    }
2815
2816    // ========================================================================
2817    // Bidirectional Relationship Sync (back_populates)
2818    // ========================================================================
2819
2820    /// Relate a child to a parent with bidirectional sync.
2821    ///
2822    /// Sets the parent on the child (ManyToOne side) and adds the child to the
2823    /// parent's collection (OneToMany side) if `back_populates` is defined.
2824    ///
2825    /// # Example
2826    ///
2827    /// ```ignore
2828    /// // Hero has a ManyToOne relationship to Team (hero.team)
2829    /// // Team has a OneToMany relationship to Hero (team.heroes) with back_populates
2830    ///
2831    /// session.relate_to_one(
2832    ///     &mut hero,
2833    ///     |h| &mut h.team,
2834    ///     |h| h.team_id = team.id,  // Set FK
2835    ///     &mut team,
2836    ///     |t| &mut t.heroes,
2837    /// );
2838    /// // Now hero.team is set AND team.heroes includes hero
2839    /// ```
2840    pub fn relate_to_one<Child, Parent, FC, FP, FK>(
2841        &self,
2842        child: &mut Child,
2843        child_accessor: FC,
2844        set_fk: FK,
2845        parent: &mut Parent,
2846        parent_accessor: FP,
2847    ) where
2848        Child: Model + Clone + 'static,
2849        Parent: Model + Clone + 'static,
2850        FC: FnOnce(&mut Child) -> &mut sqlmodel_core::Related<Parent>,
2851        FP: FnOnce(&mut Parent) -> &mut sqlmodel_core::RelatedMany<Child>,
2852        FK: FnOnce(&mut Child),
2853    {
2854        // Set the forward direction: child.parent = Related::loaded(parent)
2855        let related = child_accessor(child);
2856        let _ = related.set_loaded(Some(parent.clone()));
2857
2858        // Set the FK value
2859        set_fk(child);
2860
2861        // Set the reverse direction: parent.children.link(child)
2862        let related_many = parent_accessor(parent);
2863        related_many.link(child);
2864
2865        tracing::debug!(
2866            child_model = std::any::type_name::<Child>(),
2867            parent_model = std::any::type_name::<Parent>(),
2868            "Established bidirectional ManyToOne <-> OneToMany relationship"
2869        );
2870    }
2871
2872    /// Unrelate a child from a parent with bidirectional sync.
2873    ///
2874    /// Clears the parent on the child and removes the child from the parent's collection.
2875    ///
2876    /// # Example
2877    ///
2878    /// ```ignore
2879    /// session.unrelate_from_one(
2880    ///     &mut hero,
2881    ///     |h| &mut h.team,
2882    ///     |h| h.team_id = None,  // Clear FK
2883    ///     &mut team,
2884    ///     |t| &mut t.heroes,
2885    /// );
2886    /// ```
2887    pub fn unrelate_from_one<Child, Parent, FC, FP, FK>(
2888        &self,
2889        child: &mut Child,
2890        child_accessor: FC,
2891        clear_fk: FK,
2892        parent: &mut Parent,
2893        parent_accessor: FP,
2894    ) where
2895        Child: Model + Clone + 'static,
2896        Parent: Model + Clone + 'static,
2897        FC: FnOnce(&mut Child) -> &mut sqlmodel_core::Related<Parent>,
2898        FP: FnOnce(&mut Parent) -> &mut sqlmodel_core::RelatedMany<Child>,
2899        FK: FnOnce(&mut Child),
2900    {
2901        // Clear the forward direction by assigning an empty Related
2902        let related = child_accessor(child);
2903        *related = sqlmodel_core::Related::empty();
2904
2905        // Clear the FK value
2906        clear_fk(child);
2907
2908        // Remove from the reverse direction
2909        let related_many = parent_accessor(parent);
2910        related_many.unlink(child);
2911
2912        tracing::debug!(
2913            child_model = std::any::type_name::<Child>(),
2914            parent_model = std::any::type_name::<Parent>(),
2915            "Removed bidirectional ManyToOne <-> OneToMany relationship"
2916        );
2917    }
2918
2919    /// Relate two objects in a many-to-many relationship with bidirectional sync.
2920    ///
2921    /// Adds each object to the other's collection.
2922    ///
2923    /// # Example
2924    ///
2925    /// ```ignore
2926    /// // Hero has ManyToMany to Power via hero_powers link table
2927    /// // Power has ManyToMany to Hero via hero_powers link table (back_populates)
2928    ///
2929    /// session.relate_many_to_many(
2930    ///     &mut hero,
2931    ///     |h| &mut h.powers,
2932    ///     &mut power,
2933    ///     |p| &mut p.heroes,
2934    /// );
2935    /// // Now hero.powers includes power AND power.heroes includes hero
2936    /// ```
2937    pub fn relate_many_to_many<Left, Right, FL, FR>(
2938        &self,
2939        left: &mut Left,
2940        left_accessor: FL,
2941        right: &mut Right,
2942        right_accessor: FR,
2943    ) where
2944        Left: Model + Clone + 'static,
2945        Right: Model + Clone + 'static,
2946        FL: FnOnce(&mut Left) -> &mut sqlmodel_core::RelatedMany<Right>,
2947        FR: FnOnce(&mut Right) -> &mut sqlmodel_core::RelatedMany<Left>,
2948    {
2949        // Add right to left's collection
2950        let left_coll = left_accessor(left);
2951        left_coll.link(right);
2952
2953        // Add left to right's collection (back_populates)
2954        let right_coll = right_accessor(right);
2955        right_coll.link(left);
2956
2957        tracing::debug!(
2958            left_model = std::any::type_name::<Left>(),
2959            right_model = std::any::type_name::<Right>(),
2960            "Established bidirectional ManyToMany relationship"
2961        );
2962    }
2963
2964    /// Unrelate two objects in a many-to-many relationship with bidirectional sync.
2965    ///
2966    /// Removes each object from the other's collection.
2967    pub fn unrelate_many_to_many<Left, Right, FL, FR>(
2968        &self,
2969        left: &mut Left,
2970        left_accessor: FL,
2971        right: &mut Right,
2972        right_accessor: FR,
2973    ) where
2974        Left: Model + Clone + 'static,
2975        Right: Model + Clone + 'static,
2976        FL: FnOnce(&mut Left) -> &mut sqlmodel_core::RelatedMany<Right>,
2977        FR: FnOnce(&mut Right) -> &mut sqlmodel_core::RelatedMany<Left>,
2978    {
2979        // Remove right from left's collection
2980        let left_coll = left_accessor(left);
2981        left_coll.unlink(right);
2982
2983        // Remove left from right's collection (back_populates)
2984        let right_coll = right_accessor(right);
2985        right_coll.unlink(left);
2986
2987        tracing::debug!(
2988            left_model = std::any::type_name::<Left>(),
2989            right_model = std::any::type_name::<Right>(),
2990            "Removed bidirectional ManyToMany relationship"
2991        );
2992    }
2993
2994    // ========================================================================
2995    // N+1 Query Detection
2996    // ========================================================================
2997
2998    /// Enable N+1 query detection with the specified threshold.
2999    ///
3000    /// When the number of lazy loads for a single relationship reaches the
3001    /// threshold, a warning is emitted suggesting batch loading.
3002    ///
3003    /// # Example
3004    ///
3005    /// ```ignore
3006    /// session.enable_n1_detection(3);  // Warn after 3 lazy loads
3007    ///
3008    /// // This will trigger a warning:
3009    /// for hero in &mut heroes {
3010    ///     hero.team.load(&mut session).await?;
3011    /// }
3012    ///
3013    /// // Check stats
3014    /// if let Some(stats) = session.n1_stats() {
3015    ///     println!("Potential N+1 issues: {}", stats.potential_n1);
3016    /// }
3017    /// ```
3018    pub fn enable_n1_detection(&mut self, threshold: usize) {
3019        self.n1_tracker = Some(N1QueryTracker::new().with_threshold(threshold));
3020    }
3021
3022    /// Disable N+1 query detection and clear the tracker.
3023    pub fn disable_n1_detection(&mut self) {
3024        self.n1_tracker = None;
3025    }
3026
3027    /// Check if N+1 detection is enabled.
3028    #[must_use]
3029    pub fn n1_detection_enabled(&self) -> bool {
3030        self.n1_tracker.is_some()
3031    }
3032
3033    /// Get mutable access to the N+1 tracker (for recording loads).
3034    pub fn n1_tracker_mut(&mut self) -> Option<&mut N1QueryTracker> {
3035        self.n1_tracker.as_mut()
3036    }
3037
3038    /// Get N+1 detection statistics.
3039    #[must_use]
3040    pub fn n1_stats(&self) -> Option<N1Stats> {
3041        self.n1_tracker.as_ref().map(|t| t.stats())
3042    }
3043
3044    /// Reset N+1 detection counts (call at start of new request/transaction).
3045    pub fn reset_n1_tracking(&mut self) {
3046        if let Some(tracker) = &mut self.n1_tracker {
3047            tracker.reset();
3048        }
3049    }
3050
3051    /// Record a lazy load for N+1 detection.
3052    ///
3053    /// This is called automatically by lazy loading methods.
3054    #[track_caller]
3055    pub fn record_lazy_load(&mut self, parent_type: &'static str, relationship: &'static str) {
3056        if let Some(tracker) = &mut self.n1_tracker {
3057            tracker.record_load(parent_type, relationship);
3058        }
3059    }
3060
3061    // ========================================================================
3062    // Merge (Detached Object Reattachment)
3063    // ========================================================================
3064
3065    /// Merge a detached object back into the session.
3066    ///
3067    /// This method reattaches a detached or externally-created object to the session,
3068    /// copying its state to the session-tracked instance if one exists.
3069    ///
3070    /// # Behavior
3071    ///
3072    /// 1. **If object with same PK exists in session**: Updates the tracked object
3073    ///    with values from the provided object and returns a clone of the tracked version.
3074    ///
3075    /// 2. **If `load` is true and object not in session**: Queries the database for
3076    ///    an existing row, merges the provided values onto it, and tracks it.
3077    ///
3078    /// 3. **If object not in session or DB**: Treats it as new (will INSERT on flush).
3079    ///
3080    /// # Example
3081    ///
3082    /// ```ignore
3083    /// // Object from previous session or external source
3084    /// let mut detached_user = User { id: Some(1), name: "Updated Name".into(), .. };
3085    ///
3086    /// // Merge into current session
3087    /// let attached_user = session.merge(&cx, detached_user, true).await?;
3088    ///
3089    /// // attached_user is now tracked, changes will be persisted on flush
3090    /// session.flush(&cx).await?;
3091    /// ```
3092    ///
3093    /// # Parameters
3094    ///
3095    /// - `cx`: The async context for database operations.
3096    /// - `model`: The detached model instance to merge.
3097    /// - `load`: If true, load from database when not in identity map.
3098    ///
3099    /// # Returns
3100    ///
3101    /// The session-attached version of the object. If the object was already tracked,
3102    /// returns a clone of the updated tracked object. Otherwise, returns a clone of
3103    /// the newly tracked object.
3104    #[tracing::instrument(level = "debug", skip(self, cx, model), fields(table = M::TABLE_NAME))]
3105    pub async fn merge<
3106        M: Model + Clone + Send + Sync + Serialize + for<'de> Deserialize<'de> + 'static,
3107    >(
3108        &mut self,
3109        cx: &Cx,
3110        model: M,
3111        load: bool,
3112    ) -> Outcome<M, Error> {
3113        let pk_values = model.primary_key_value();
3114        let key = ObjectKey::from_model(&model);
3115
3116        tracing::debug!(
3117            pk = ?pk_values,
3118            load = load,
3119            in_identity_map = self.identity_map.contains_key(&key),
3120            "Merging object"
3121        );
3122
3123        // 1. Check identity map first
3124        if let Some(tracked) = self.identity_map.get_mut(&key) {
3125            // Skip if detached - we shouldn't merge into detached objects
3126            if tracked.state == ObjectState::Detached {
3127                tracing::debug!("Found detached object, treating as new");
3128            } else {
3129                tracing::debug!(
3130                    state = ?tracked.state,
3131                    "Found tracked object, updating with merged values"
3132                );
3133
3134                // Update the tracked object with values from the provided model
3135                let row_data = model.to_row();
3136                tracked.object = Box::new(model.clone());
3137                tracked.column_names = row_data.iter().map(|(name, _)| *name).collect();
3138                tracked.values = row_data.into_iter().map(|(_, v)| v).collect();
3139                tracked.pk_values.clone_from(&pk_values);
3140
3141                // If persistent, mark as dirty for UPDATE
3142                if tracked.state == ObjectState::Persistent && !self.pending_dirty.contains(&key) {
3143                    self.pending_dirty.push(key);
3144                }
3145
3146                // Return clone of the tracked object
3147                if let Some(obj) = tracked.object.downcast_ref::<M>() {
3148                    return Outcome::Ok(obj.clone());
3149                }
3150            }
3151        }
3152
3153        // 2. If load=true, try to fetch from database
3154        if load {
3155            // Check if we have a valid primary key (not null/default)
3156            let has_valid_pk = pk_values
3157                .iter()
3158                .all(|v| !matches!(v, Value::Null | Value::Default));
3159
3160            if has_valid_pk {
3161                tracing::debug!("Loading from database");
3162
3163                let db_result = self.get_by_pk::<M>(cx, &pk_values).await;
3164                match db_result {
3165                    Outcome::Ok(Some(_existing)) => {
3166                        // Now update the tracked object (which was added by get_by_pk)
3167                        // with the values from our model
3168                        if let Some(tracked) = self.identity_map.get_mut(&key) {
3169                            let row_data = model.to_row();
3170                            tracked.object = Box::new(model.clone());
3171                            tracked.column_names = row_data.iter().map(|(name, _)| *name).collect();
3172                            tracked.values = row_data.into_iter().map(|(_, v)| v).collect();
3173                            // pk_values stay the same from DB
3174
3175                            // Mark as dirty since we're updating with new values
3176                            if !self.pending_dirty.contains(&key) {
3177                                self.pending_dirty.push(key);
3178                            }
3179
3180                            tracing::debug!("Merged values onto DB object");
3181
3182                            if let Some(obj) = tracked.object.downcast_ref::<M>() {
3183                                return Outcome::Ok(obj.clone());
3184                            }
3185                        }
3186                    }
3187                    Outcome::Ok(None) => {
3188                        tracing::debug!("Object not found in database, treating as new");
3189                    }
3190                    Outcome::Err(e) => return Outcome::Err(e),
3191                    Outcome::Cancelled(r) => return Outcome::Cancelled(r),
3192                    Outcome::Panicked(p) => return Outcome::Panicked(p),
3193                }
3194            }
3195        }
3196
3197        // 3. Treat as new - add to session
3198        tracing::debug!("Adding as new object");
3199        self.add(&model);
3200
3201        Outcome::Ok(model)
3202    }
3203
3204    /// Merge a detached object without loading from database.
3205    ///
3206    /// This is a convenience method equivalent to `merge(cx, model, false)`.
3207    /// Use this when you know the object doesn't exist in the database or
3208    /// you don't want to query the database.
3209    ///
3210    /// # Example
3211    ///
3212    /// ```ignore
3213    /// let attached = session.merge_without_load(&cx, detached_user).await?;
3214    /// ```
3215    pub async fn merge_without_load<
3216        M: Model + Clone + Send + Sync + Serialize + for<'de> Deserialize<'de> + 'static,
3217    >(
3218        &mut self,
3219        cx: &Cx,
3220        model: M,
3221    ) -> Outcome<M, Error> {
3222        self.merge(cx, model, false).await
3223    }
3224
3225    // ========================================================================
3226    // Debug Diagnostics
3227    // ========================================================================
3228
3229    /// Get count of objects pending INSERT.
3230    pub fn pending_new_count(&self) -> usize {
3231        self.pending_new.len()
3232    }
3233
3234    /// Get count of objects pending DELETE.
3235    pub fn pending_delete_count(&self) -> usize {
3236        self.pending_delete.len()
3237    }
3238
3239    /// Get count of dirty objects pending UPDATE.
3240    pub fn pending_dirty_count(&self) -> usize {
3241        self.pending_dirty.len()
3242    }
3243
3244    /// Get total tracked object count.
3245    pub fn tracked_count(&self) -> usize {
3246        self.identity_map.len()
3247    }
3248
3249    /// Whether we're in a transaction.
3250    pub fn in_transaction(&self) -> bool {
3251        self.in_transaction
3252    }
3253
3254    /// Dump session state for debugging.
3255    pub fn debug_state(&self) -> SessionDebugInfo {
3256        SessionDebugInfo {
3257            tracked: self.tracked_count(),
3258            pending_new: self.pending_new_count(),
3259            pending_delete: self.pending_delete_count(),
3260            pending_dirty: self.pending_dirty_count(),
3261            in_transaction: self.in_transaction,
3262        }
3263    }
3264
3265    // ========================================================================
3266    // Bulk Operations
3267    // ========================================================================
3268
3269    /// Bulk insert multiple model instances without object tracking.
3270    ///
3271    /// This generates a single multi-row INSERT statement and bypasses
3272    /// the identity map entirely, making it much faster for large batches.
3273    ///
3274    /// Models are inserted in chunks of `batch_size` to avoid excessively
3275    /// large SQL statements. The default batch size is 1000.
3276    ///
3277    /// Returns the total number of rows inserted.
3278    pub async fn bulk_insert<M: Model + Clone + Send + Sync + 'static>(
3279        &mut self,
3280        cx: &Cx,
3281        models: &[M],
3282    ) -> Outcome<u64, Error> {
3283        self.bulk_insert_with_batch_size(cx, models, 1000).await
3284    }
3285
3286    /// Bulk insert with a custom batch size.
3287    pub async fn bulk_insert_with_batch_size<M: Model + Clone + Send + Sync + 'static>(
3288        &mut self,
3289        cx: &Cx,
3290        models: &[M],
3291        batch_size: usize,
3292    ) -> Outcome<u64, Error> {
3293        if models.is_empty() {
3294            return Outcome::Ok(0);
3295        }
3296
3297        let batch_size = batch_size.max(1);
3298        let mut total_inserted: u64 = 0;
3299
3300        for chunk in models.chunks(batch_size) {
3301            let builder = sqlmodel_query::InsertManyBuilder::new(chunk);
3302            match builder.execute(cx, &self.connection).await {
3303                Outcome::Ok(count) => total_inserted += count,
3304                Outcome::Err(e) => return Outcome::Err(e),
3305                Outcome::Cancelled(r) => return Outcome::Cancelled(r),
3306                Outcome::Panicked(p) => return Outcome::Panicked(p),
3307            }
3308        }
3309
3310        Outcome::Ok(total_inserted)
3311    }
3312
3313    /// Bulk update multiple model instances without individual tracking.
3314    ///
3315    /// Each model is updated individually using its primary key, but
3316    /// all updates are executed in a single transaction without going
3317    /// through the identity map or change tracking.
3318    ///
3319    /// Returns the total number of rows updated.
3320    pub async fn bulk_update<M: Model + Clone + Send + Sync + 'static>(
3321        &mut self,
3322        cx: &Cx,
3323        models: &[M],
3324    ) -> Outcome<u64, Error> {
3325        if models.is_empty() {
3326            return Outcome::Ok(0);
3327        }
3328
3329        let mut total_updated: u64 = 0;
3330
3331        for model in models {
3332            let builder = sqlmodel_query::UpdateBuilder::new(model);
3333            let (sql, params) = builder.build_with_dialect(self.connection.dialect());
3334
3335            if sql.is_empty() {
3336                continue;
3337            }
3338
3339            match self.connection.execute(cx, &sql, &params).await {
3340                Outcome::Ok(count) => total_updated += count,
3341                Outcome::Err(e) => return Outcome::Err(e),
3342                Outcome::Cancelled(r) => return Outcome::Cancelled(r),
3343                Outcome::Panicked(p) => return Outcome::Panicked(p),
3344            }
3345        }
3346
3347        Outcome::Ok(total_updated)
3348    }
3349}
3350
3351impl<C, M> LazyLoader<M> for Session<C>
3352where
3353    C: Connection,
3354    M: Model + Clone + Send + Sync + Serialize + for<'de> Deserialize<'de> + 'static,
3355{
3356    fn get(
3357        &mut self,
3358        cx: &Cx,
3359        pk: Value,
3360    ) -> impl Future<Output = Outcome<Option<M>, Error>> + Send {
3361        Session::get(self, cx, pk)
3362    }
3363}
3364
3365/// Debug information about session state.
3366#[derive(Debug, Clone)]
3367pub struct SessionDebugInfo {
3368    /// Total tracked objects.
3369    pub tracked: usize,
3370    /// Objects pending INSERT.
3371    pub pending_new: usize,
3372    /// Objects pending DELETE.
3373    pub pending_delete: usize,
3374    /// Objects pending UPDATE.
3375    pub pending_dirty: usize,
3376    /// Whether in a transaction.
3377    pub in_transaction: bool,
3378}
3379
3380// ============================================================================
3381// Unit Tests
3382// ============================================================================
3383
3384#[cfg(test)]
3385#[allow(clippy::manual_async_fn)] // Mock trait impls must match trait signatures
3386mod tests {
3387    use super::*;
3388    use asupersync::runtime::RuntimeBuilder;
3389    use sqlmodel_core::Row;
3390    use std::sync::{Arc, Mutex};
3391
3392    #[test]
3393    fn test_session_config_defaults() {
3394        let config = SessionConfig::default();
3395        assert!(config.auto_begin);
3396        assert!(!config.auto_flush);
3397        assert!(config.expire_on_commit);
3398    }
3399
3400    #[test]
3401    fn test_object_key_hash_consistency() {
3402        let values1 = vec![Value::BigInt(42)];
3403        let values2 = vec![Value::BigInt(42)];
3404        let hash1 = hash_values(&values1);
3405        let hash2 = hash_values(&values2);
3406        assert_eq!(hash1, hash2);
3407    }
3408
3409    #[test]
3410    fn test_object_key_hash_different_values() {
3411        let values1 = vec![Value::BigInt(42)];
3412        let values2 = vec![Value::BigInt(43)];
3413        let hash1 = hash_values(&values1);
3414        let hash2 = hash_values(&values2);
3415        assert_ne!(hash1, hash2);
3416    }
3417
3418    #[test]
3419    fn test_object_key_hash_different_types() {
3420        let values1 = vec![Value::BigInt(42)];
3421        let values2 = vec![Value::Text("42".to_string())];
3422        let hash1 = hash_values(&values1);
3423        let hash2 = hash_values(&values2);
3424        assert_ne!(hash1, hash2);
3425    }
3426
3427    #[test]
3428    fn test_session_debug_info() {
3429        let info = SessionDebugInfo {
3430            tracked: 5,
3431            pending_new: 2,
3432            pending_delete: 1,
3433            pending_dirty: 0,
3434            in_transaction: true,
3435        };
3436        assert_eq!(info.tracked, 5);
3437        assert_eq!(info.pending_new, 2);
3438        assert!(info.in_transaction);
3439    }
3440
3441    fn unwrap_outcome<T: std::fmt::Debug>(outcome: Outcome<T, Error>) -> T {
3442        match outcome {
3443            Outcome::Ok(v) => v,
3444            other => std::panic::panic_any(format!("unexpected outcome: {other:?}")),
3445        }
3446    }
3447
3448    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3449    struct Team {
3450        id: Option<i64>,
3451        name: String,
3452    }
3453
3454    impl Model for Team {
3455        const TABLE_NAME: &'static str = "teams";
3456        const PRIMARY_KEY: &'static [&'static str] = &["id"];
3457
3458        fn fields() -> &'static [sqlmodel_core::FieldInfo] {
3459            &[]
3460        }
3461
3462        fn to_row(&self) -> Vec<(&'static str, Value)> {
3463            vec![
3464                ("id", self.id.map_or(Value::Null, Value::BigInt)),
3465                ("name", Value::Text(self.name.clone())),
3466            ]
3467        }
3468
3469        fn from_row(row: &Row) -> sqlmodel_core::Result<Self> {
3470            let id: i64 = row.get_named("id")?;
3471            let name: String = row.get_named("name")?;
3472            Ok(Self { id: Some(id), name })
3473        }
3474
3475        fn primary_key_value(&self) -> Vec<Value> {
3476            self.id
3477                .map_or_else(|| vec![Value::Null], |id| vec![Value::BigInt(id)])
3478        }
3479
3480        fn is_new(&self) -> bool {
3481            self.id.is_none()
3482        }
3483    }
3484
3485    #[derive(Debug, Clone, Serialize, Deserialize)]
3486    struct Hero {
3487        id: Option<i64>,
3488        team: Lazy<Team>,
3489    }
3490
3491    impl Model for Hero {
3492        const TABLE_NAME: &'static str = "heroes";
3493        const PRIMARY_KEY: &'static [&'static str] = &["id"];
3494
3495        fn fields() -> &'static [sqlmodel_core::FieldInfo] {
3496            &[]
3497        }
3498
3499        fn to_row(&self) -> Vec<(&'static str, Value)> {
3500            vec![]
3501        }
3502
3503        fn from_row(_row: &Row) -> sqlmodel_core::Result<Self> {
3504            Ok(Self {
3505                id: None,
3506                team: Lazy::empty(),
3507            })
3508        }
3509
3510        fn primary_key_value(&self) -> Vec<Value> {
3511            self.id
3512                .map_or_else(|| vec![Value::Null], |id| vec![Value::BigInt(id)])
3513        }
3514
3515        fn is_new(&self) -> bool {
3516            self.id.is_none()
3517        }
3518    }
3519
3520    #[derive(Debug, Default)]
3521    struct MockState {
3522        query_calls: usize,
3523        last_sql: Option<String>,
3524        execute_calls: usize,
3525        executed: Vec<(String, Vec<Value>)>,
3526    }
3527
3528    #[derive(Debug, Clone)]
3529    struct MockConnection {
3530        state: Arc<Mutex<MockState>>,
3531        dialect: sqlmodel_core::Dialect,
3532    }
3533
3534    impl MockConnection {
3535        fn new(state: Arc<Mutex<MockState>>) -> Self {
3536            Self {
3537                state,
3538                dialect: sqlmodel_core::Dialect::Postgres,
3539            }
3540        }
3541    }
3542
3543    impl sqlmodel_core::Connection for MockConnection {
3544        type Tx<'conn>
3545            = MockTransaction
3546        where
3547            Self: 'conn;
3548
3549        fn dialect(&self) -> sqlmodel_core::Dialect {
3550            self.dialect
3551        }
3552
3553        fn query(
3554            &self,
3555            _cx: &Cx,
3556            sql: &str,
3557            params: &[Value],
3558        ) -> impl Future<Output = Outcome<Vec<Row>, Error>> + Send {
3559            let params = params.to_vec();
3560            let state = Arc::clone(&self.state);
3561            let sql = sql.to_string();
3562            async move {
3563                {
3564                    let mut guard = state.lock().expect("lock poisoned");
3565                    guard.query_calls += 1;
3566                    guard.last_sql = Some(sql.clone());
3567                }
3568
3569                let mut rows = Vec::new();
3570                let is_teams = sql.contains("teams");
3571                let is_heroes = sql.contains("heroes");
3572
3573                for v in params {
3574                    if is_teams {
3575                        match v {
3576                            Value::BigInt(1) => rows.push(Row::new(
3577                                vec!["id".into(), "name".into()],
3578                                vec![Value::BigInt(1), Value::Text("Avengers".into())],
3579                            )),
3580                            Value::BigInt(2) => rows.push(Row::new(
3581                                vec!["id".into(), "name".into()],
3582                                vec![Value::BigInt(2), Value::Text("X-Men".into())],
3583                            )),
3584                            _ => {}
3585                        }
3586                    } else if is_heroes {
3587                        // One-to-many child rows keyed by team_id (the query parameter).
3588                        match v {
3589                            Value::BigInt(1) => {
3590                                rows.push(Row::new(
3591                                    vec!["id".into(), "team_id".into(), "__parent_pk".into()],
3592                                    vec![Value::BigInt(101), Value::BigInt(1), Value::BigInt(1)],
3593                                ));
3594                                rows.push(Row::new(
3595                                    vec!["id".into(), "team_id".into(), "__parent_pk".into()],
3596                                    vec![Value::BigInt(102), Value::BigInt(1), Value::BigInt(1)],
3597                                ));
3598                            }
3599                            Value::BigInt(2) => rows.push(Row::new(
3600                                vec!["id".into(), "team_id".into(), "__parent_pk".into()],
3601                                vec![Value::BigInt(201), Value::BigInt(2), Value::BigInt(2)],
3602                            )),
3603                            _ => {}
3604                        }
3605                    }
3606                }
3607
3608                Outcome::Ok(rows)
3609            }
3610        }
3611
3612        fn query_one(
3613            &self,
3614            _cx: &Cx,
3615            _sql: &str,
3616            _params: &[Value],
3617        ) -> impl Future<Output = Outcome<Option<Row>, Error>> + Send {
3618            async { Outcome::Ok(None) }
3619        }
3620
3621        fn execute(
3622            &self,
3623            _cx: &Cx,
3624            sql: &str,
3625            params: &[Value],
3626        ) -> impl Future<Output = Outcome<u64, Error>> + Send {
3627            let state = Arc::clone(&self.state);
3628            let sql = sql.to_string();
3629            let params = params.to_vec();
3630            async move {
3631                let mut guard = state.lock().expect("lock poisoned");
3632                guard.execute_calls += 1;
3633                guard.executed.push((sql, params));
3634                Outcome::Ok(0)
3635            }
3636        }
3637
3638        fn insert(
3639            &self,
3640            _cx: &Cx,
3641            _sql: &str,
3642            _params: &[Value],
3643        ) -> impl Future<Output = Outcome<i64, Error>> + Send {
3644            async { Outcome::Ok(0) }
3645        }
3646
3647        fn batch(
3648            &self,
3649            _cx: &Cx,
3650            _statements: &[(String, Vec<Value>)],
3651        ) -> impl Future<Output = Outcome<Vec<u64>, Error>> + Send {
3652            async { Outcome::Ok(vec![]) }
3653        }
3654
3655        fn begin(&self, _cx: &Cx) -> impl Future<Output = Outcome<Self::Tx<'_>, Error>> + Send {
3656            async { Outcome::Ok(MockTransaction) }
3657        }
3658
3659        fn begin_with(
3660            &self,
3661            _cx: &Cx,
3662            _isolation: sqlmodel_core::connection::IsolationLevel,
3663        ) -> impl Future<Output = Outcome<Self::Tx<'_>, Error>> + Send {
3664            async { Outcome::Ok(MockTransaction) }
3665        }
3666
3667        fn prepare(
3668            &self,
3669            _cx: &Cx,
3670            _sql: &str,
3671        ) -> impl Future<Output = Outcome<sqlmodel_core::connection::PreparedStatement, Error>> + Send
3672        {
3673            async {
3674                Outcome::Ok(sqlmodel_core::connection::PreparedStatement::new(
3675                    0,
3676                    String::new(),
3677                    0,
3678                ))
3679            }
3680        }
3681
3682        fn query_prepared(
3683            &self,
3684            _cx: &Cx,
3685            _stmt: &sqlmodel_core::connection::PreparedStatement,
3686            _params: &[Value],
3687        ) -> impl Future<Output = Outcome<Vec<Row>, Error>> + Send {
3688            async { Outcome::Ok(vec![]) }
3689        }
3690
3691        fn execute_prepared(
3692            &self,
3693            _cx: &Cx,
3694            _stmt: &sqlmodel_core::connection::PreparedStatement,
3695            _params: &[Value],
3696        ) -> impl Future<Output = Outcome<u64, Error>> + Send {
3697            async { Outcome::Ok(0) }
3698        }
3699
3700        fn ping(&self, _cx: &Cx) -> impl Future<Output = Outcome<(), Error>> + Send {
3701            async { Outcome::Ok(()) }
3702        }
3703
3704        fn close(self, _cx: &Cx) -> impl Future<Output = sqlmodel_core::Result<()>> + Send {
3705            async { Ok(()) }
3706        }
3707    }
3708
3709    struct MockTransaction;
3710
3711    impl sqlmodel_core::connection::TransactionOps for MockTransaction {
3712        fn query(
3713            &self,
3714            _cx: &Cx,
3715            _sql: &str,
3716            _params: &[Value],
3717        ) -> impl Future<Output = Outcome<Vec<Row>, Error>> + Send {
3718            async { Outcome::Ok(vec![]) }
3719        }
3720
3721        fn query_one(
3722            &self,
3723            _cx: &Cx,
3724            _sql: &str,
3725            _params: &[Value],
3726        ) -> impl Future<Output = Outcome<Option<Row>, Error>> + Send {
3727            async { Outcome::Ok(None) }
3728        }
3729
3730        fn execute(
3731            &self,
3732            _cx: &Cx,
3733            _sql: &str,
3734            _params: &[Value],
3735        ) -> impl Future<Output = Outcome<u64, Error>> + Send {
3736            async { Outcome::Ok(0) }
3737        }
3738
3739        fn savepoint(
3740            &self,
3741            _cx: &Cx,
3742            _name: &str,
3743        ) -> impl Future<Output = Outcome<(), Error>> + Send {
3744            async { Outcome::Ok(()) }
3745        }
3746
3747        fn rollback_to(
3748            &self,
3749            _cx: &Cx,
3750            _name: &str,
3751        ) -> impl Future<Output = Outcome<(), Error>> + Send {
3752            async { Outcome::Ok(()) }
3753        }
3754
3755        fn release(
3756            &self,
3757            _cx: &Cx,
3758            _name: &str,
3759        ) -> impl Future<Output = Outcome<(), Error>> + Send {
3760            async { Outcome::Ok(()) }
3761        }
3762
3763        fn commit(self, _cx: &Cx) -> impl Future<Output = Outcome<(), Error>> + Send {
3764            async { Outcome::Ok(()) }
3765        }
3766
3767        fn rollback(self, _cx: &Cx) -> impl Future<Output = Outcome<(), Error>> + Send {
3768            async { Outcome::Ok(()) }
3769        }
3770    }
3771
3772    #[test]
3773    fn test_load_many_single_query_and_populates_lazy() {
3774        let rt = RuntimeBuilder::current_thread()
3775            .build()
3776            .expect("create asupersync runtime");
3777        let cx = Cx::for_testing();
3778
3779        let state = Arc::new(Mutex::new(MockState::default()));
3780        let conn = MockConnection::new(Arc::clone(&state));
3781        let mut session = Session::new(conn);
3782
3783        let heroes = vec![
3784            Hero {
3785                id: Some(1),
3786                team: Lazy::from_fk(1_i64),
3787            },
3788            Hero {
3789                id: Some(2),
3790                team: Lazy::from_fk(2_i64),
3791            },
3792            Hero {
3793                id: Some(3),
3794                team: Lazy::from_fk(1_i64),
3795            },
3796            Hero {
3797                id: Some(4),
3798                team: Lazy::empty(),
3799            },
3800            Hero {
3801                id: Some(5),
3802                team: Lazy::from_fk(999_i64),
3803            },
3804        ];
3805
3806        rt.block_on(async {
3807            let loaded = unwrap_outcome(
3808                session
3809                    .load_many::<Hero, Team, _>(&cx, &heroes, |h| &h.team)
3810                    .await,
3811            );
3812            assert_eq!(loaded, 3);
3813
3814            // Populated / cached
3815            assert!(heroes[0].team.is_loaded());
3816            assert_eq!(heroes[0].team.get().unwrap().name, "Avengers");
3817            assert_eq!(heroes[1].team.get().unwrap().name, "X-Men");
3818            assert_eq!(heroes[2].team.get().unwrap().name, "Avengers");
3819
3820            // Empty FK gets cached as loaded-none
3821            assert!(heroes[3].team.is_loaded());
3822            assert!(heroes[3].team.get().is_none());
3823
3824            // Missing object gets cached as loaded-none
3825            assert!(heroes[4].team.is_loaded());
3826            assert!(heroes[4].team.get().is_none());
3827
3828            // Identity map populated: get() should not hit the connection again
3829            let team1 = unwrap_outcome(session.get::<Team>(&cx, 1_i64).await);
3830            assert_eq!(
3831                team1,
3832                Some(Team {
3833                    id: Some(1),
3834                    name: "Avengers".to_string()
3835                })
3836            );
3837        });
3838
3839        assert_eq!(state.lock().expect("lock poisoned").query_calls, 1);
3840    }
3841
3842    #[derive(Debug, Clone, Serialize, Deserialize)]
3843    struct HeroChild {
3844        id: Option<i64>,
3845        team_id: i64,
3846    }
3847
3848    impl Model for HeroChild {
3849        const TABLE_NAME: &'static str = "heroes";
3850        const PRIMARY_KEY: &'static [&'static str] = &["id"];
3851
3852        fn fields() -> &'static [sqlmodel_core::FieldInfo] {
3853            &[]
3854        }
3855
3856        fn to_row(&self) -> Vec<(&'static str, Value)> {
3857            vec![
3858                ("id", self.id.map_or(Value::Null, Value::BigInt)),
3859                ("team_id", Value::BigInt(self.team_id)),
3860            ]
3861        }
3862
3863        fn from_row(row: &Row) -> sqlmodel_core::Result<Self> {
3864            let id: i64 = row.get_named("id")?;
3865            let team_id: i64 = row.get_named("team_id")?;
3866            Ok(Self {
3867                id: Some(id),
3868                team_id,
3869            })
3870        }
3871
3872        fn primary_key_value(&self) -> Vec<Value> {
3873            self.id
3874                .map_or_else(|| vec![Value::Null], |id| vec![Value::BigInt(id)])
3875        }
3876
3877        fn is_new(&self) -> bool {
3878            self.id.is_none()
3879        }
3880    }
3881
3882    #[derive(Debug, Clone, Serialize, Deserialize)]
3883    struct TeamWithHeroes {
3884        id: Option<i64>,
3885        heroes: sqlmodel_core::RelatedMany<HeroChild>,
3886    }
3887
3888    impl Model for TeamWithHeroes {
3889        const TABLE_NAME: &'static str = "teams";
3890        const PRIMARY_KEY: &'static [&'static str] = &["id"];
3891        const RELATIONSHIPS: &'static [sqlmodel_core::RelationshipInfo] =
3892            &[sqlmodel_core::RelationshipInfo::new(
3893                "heroes",
3894                "heroes",
3895                sqlmodel_core::RelationshipKind::OneToMany,
3896            )
3897            .remote_key("team_id")
3898            .cascade_delete(true)];
3899
3900        fn fields() -> &'static [sqlmodel_core::FieldInfo] {
3901            &[]
3902        }
3903
3904        fn to_row(&self) -> Vec<(&'static str, Value)> {
3905            vec![("id", self.id.map_or(Value::Null, Value::BigInt))]
3906        }
3907
3908        fn from_row(row: &Row) -> sqlmodel_core::Result<Self> {
3909            let id: i64 = row.get_named("id")?;
3910            Ok(Self {
3911                id: Some(id),
3912                heroes: sqlmodel_core::RelatedMany::new("team_id"),
3913            })
3914        }
3915
3916        fn primary_key_value(&self) -> Vec<Value> {
3917            self.id
3918                .map_or_else(|| vec![Value::Null], |id| vec![Value::BigInt(id)])
3919        }
3920
3921        fn is_new(&self) -> bool {
3922            self.id.is_none()
3923        }
3924    }
3925
3926    #[derive(Debug, Clone, Serialize, Deserialize)]
3927    struct TeamWithHeroesPassive {
3928        id: Option<i64>,
3929        heroes: sqlmodel_core::RelatedMany<HeroChild>,
3930    }
3931
3932    impl Model for TeamWithHeroesPassive {
3933        const TABLE_NAME: &'static str = "teams_passive";
3934        const PRIMARY_KEY: &'static [&'static str] = &["id"];
3935        const RELATIONSHIPS: &'static [sqlmodel_core::RelationshipInfo] =
3936            &[sqlmodel_core::RelationshipInfo::new(
3937                "heroes",
3938                "heroes",
3939                sqlmodel_core::RelationshipKind::OneToMany,
3940            )
3941            .remote_key("team_id")
3942            .cascade_delete(true)
3943            .passive_deletes(sqlmodel_core::PassiveDeletes::Passive)];
3944
3945        fn fields() -> &'static [sqlmodel_core::FieldInfo] {
3946            &[]
3947        }
3948
3949        fn to_row(&self) -> Vec<(&'static str, Value)> {
3950            vec![("id", self.id.map_or(Value::Null, Value::BigInt))]
3951        }
3952
3953        fn from_row(row: &Row) -> sqlmodel_core::Result<Self> {
3954            let id: i64 = row.get_named("id")?;
3955            Ok(Self {
3956                id: Some(id),
3957                heroes: sqlmodel_core::RelatedMany::new("team_id"),
3958            })
3959        }
3960
3961        fn primary_key_value(&self) -> Vec<Value> {
3962            self.id
3963                .map_or_else(|| vec![Value::Null], |id| vec![Value::BigInt(id)])
3964        }
3965
3966        fn is_new(&self) -> bool {
3967            self.id.is_none()
3968        }
3969    }
3970
3971    #[derive(Debug, Clone, Serialize, Deserialize)]
3972    struct HeroCompositeChild {
3973        id: Option<i64>,
3974        team_id1: i64,
3975        team_id2: i64,
3976    }
3977
3978    impl Model for HeroCompositeChild {
3979        const TABLE_NAME: &'static str = "heroes_composite";
3980        const PRIMARY_KEY: &'static [&'static str] = &["id"];
3981
3982        fn fields() -> &'static [sqlmodel_core::FieldInfo] {
3983            &[]
3984        }
3985
3986        fn to_row(&self) -> Vec<(&'static str, Value)> {
3987            vec![
3988                ("id", self.id.map_or(Value::Null, Value::BigInt)),
3989                ("team_id1", Value::BigInt(self.team_id1)),
3990                ("team_id2", Value::BigInt(self.team_id2)),
3991            ]
3992        }
3993
3994        fn from_row(row: &Row) -> sqlmodel_core::Result<Self> {
3995            let id: i64 = row.get_named("id")?;
3996            let team_id1: i64 = row.get_named("team_id1")?;
3997            let team_id2: i64 = row.get_named("team_id2")?;
3998            Ok(Self {
3999                id: Some(id),
4000                team_id1,
4001                team_id2,
4002            })
4003        }
4004
4005        fn primary_key_value(&self) -> Vec<Value> {
4006            self.id
4007                .map_or_else(|| vec![Value::Null], |id| vec![Value::BigInt(id)])
4008        }
4009
4010        fn is_new(&self) -> bool {
4011            self.id.is_none()
4012        }
4013    }
4014
4015    #[derive(Debug, Clone, Serialize, Deserialize)]
4016    struct TeamComposite {
4017        id1: Option<i64>,
4018        id2: Option<i64>,
4019    }
4020
4021    impl Model for TeamComposite {
4022        const TABLE_NAME: &'static str = "teams_composite";
4023        const PRIMARY_KEY: &'static [&'static str] = &["id1", "id2"];
4024        const RELATIONSHIPS: &'static [sqlmodel_core::RelationshipInfo] =
4025            &[sqlmodel_core::RelationshipInfo::new(
4026                "heroes",
4027                "heroes_composite",
4028                sqlmodel_core::RelationshipKind::OneToMany,
4029            )
4030            .remote_keys(&["team_id1", "team_id2"])
4031            .cascade_delete(true)];
4032
4033        fn fields() -> &'static [sqlmodel_core::FieldInfo] {
4034            &[]
4035        }
4036
4037        fn to_row(&self) -> Vec<(&'static str, Value)> {
4038            vec![
4039                ("id1", self.id1.map_or(Value::Null, Value::BigInt)),
4040                ("id2", self.id2.map_or(Value::Null, Value::BigInt)),
4041            ]
4042        }
4043
4044        fn from_row(row: &Row) -> sqlmodel_core::Result<Self> {
4045            let id1: i64 = row.get_named("id1")?;
4046            let id2: i64 = row.get_named("id2")?;
4047            Ok(Self {
4048                id1: Some(id1),
4049                id2: Some(id2),
4050            })
4051        }
4052
4053        fn primary_key_value(&self) -> Vec<Value> {
4054            match (self.id1, self.id2) {
4055                (Some(a), Some(b)) => vec![Value::BigInt(a), Value::BigInt(b)],
4056                _ => vec![Value::Null, Value::Null],
4057            }
4058        }
4059
4060        fn is_new(&self) -> bool {
4061            self.id1.is_none() || self.id2.is_none()
4062        }
4063    }
4064
4065    #[derive(Debug, Clone, Serialize, Deserialize)]
4066    struct TeamCompositePassive {
4067        id1: Option<i64>,
4068        id2: Option<i64>,
4069    }
4070
4071    impl Model for TeamCompositePassive {
4072        const TABLE_NAME: &'static str = "teams_composite_passive";
4073        const PRIMARY_KEY: &'static [&'static str] = &["id1", "id2"];
4074        const RELATIONSHIPS: &'static [sqlmodel_core::RelationshipInfo] =
4075            &[sqlmodel_core::RelationshipInfo::new(
4076                "heroes",
4077                "heroes_composite",
4078                sqlmodel_core::RelationshipKind::OneToMany,
4079            )
4080            .remote_keys(&["team_id1", "team_id2"])
4081            .cascade_delete(true)
4082            .passive_deletes(sqlmodel_core::PassiveDeletes::Passive)];
4083
4084        fn fields() -> &'static [sqlmodel_core::FieldInfo] {
4085            &[]
4086        }
4087
4088        fn to_row(&self) -> Vec<(&'static str, Value)> {
4089            vec![
4090                ("id1", self.id1.map_or(Value::Null, Value::BigInt)),
4091                ("id2", self.id2.map_or(Value::Null, Value::BigInt)),
4092            ]
4093        }
4094
4095        fn from_row(row: &Row) -> sqlmodel_core::Result<Self> {
4096            let id1: i64 = row.get_named("id1")?;
4097            let id2: i64 = row.get_named("id2")?;
4098            Ok(Self {
4099                id1: Some(id1),
4100                id2: Some(id2),
4101            })
4102        }
4103
4104        fn primary_key_value(&self) -> Vec<Value> {
4105            match (self.id1, self.id2) {
4106                (Some(a), Some(b)) => vec![Value::BigInt(a), Value::BigInt(b)],
4107                _ => vec![Value::Null, Value::Null],
4108            }
4109        }
4110
4111        fn is_new(&self) -> bool {
4112            self.id1.is_none() || self.id2.is_none()
4113        }
4114    }
4115
4116    #[test]
4117    fn test_load_one_to_many_single_query_and_populates_related_many() {
4118        let rt = RuntimeBuilder::current_thread()
4119            .build()
4120            .expect("create asupersync runtime");
4121        let cx = Cx::for_testing();
4122
4123        let state = Arc::new(Mutex::new(MockState::default()));
4124        let conn = MockConnection::new(Arc::clone(&state));
4125        let mut session = Session::new(conn);
4126
4127        let mut teams = vec![
4128            TeamWithHeroes {
4129                id: Some(1),
4130                heroes: sqlmodel_core::RelatedMany::new("team_id"),
4131            },
4132            TeamWithHeroes {
4133                id: Some(2),
4134                heroes: sqlmodel_core::RelatedMany::new("team_id"),
4135            },
4136            TeamWithHeroes {
4137                id: None,
4138                heroes: sqlmodel_core::RelatedMany::new("team_id"),
4139            },
4140        ];
4141
4142        rt.block_on(async {
4143            let loaded = unwrap_outcome(
4144                session
4145                    .load_one_to_many::<TeamWithHeroes, HeroChild, _, _>(
4146                        &cx,
4147                        &mut teams,
4148                        |t| &mut t.heroes,
4149                        |t| t.id.map_or(Value::Null, Value::BigInt),
4150                    )
4151                    .await,
4152            );
4153            assert_eq!(loaded, 3);
4154
4155            assert!(teams[0].heroes.is_loaded());
4156            assert_eq!(teams[0].heroes.len(), 2);
4157            assert_eq!(teams[0].heroes.parent_pk(), Some(&Value::BigInt(1)));
4158
4159            assert!(teams[1].heroes.is_loaded());
4160            assert_eq!(teams[1].heroes.len(), 1);
4161            assert_eq!(teams[1].heroes.parent_pk(), Some(&Value::BigInt(2)));
4162
4163            // Unsaved parent gets an empty, loaded collection without querying.
4164            assert!(teams[2].heroes.is_loaded());
4165            assert_eq!(teams[2].heroes.len(), 0);
4166            assert_eq!(teams[2].heroes.parent_pk(), Some(&Value::Null));
4167        });
4168
4169        assert_eq!(state.lock().expect("lock poisoned").query_calls, 1);
4170        let sql = state
4171            .lock()
4172            .expect("lock poisoned")
4173            .last_sql
4174            .clone()
4175            .expect("sql captured");
4176        assert!(sql.contains("FROM"), "expected SQL to contain FROM");
4177        assert!(
4178            sql.contains("heroes"),
4179            "expected SQL to target heroes table"
4180        );
4181        assert!(
4182            sql.contains("$1"),
4183            "expected Postgres-style placeholders ($1, $2, ...)"
4184        );
4185        assert!(
4186            sql.contains("$2"),
4187            "expected Postgres-style placeholders ($1, $2, ...)"
4188        );
4189    }
4190
4191    #[test]
4192    fn test_flush_cascade_delete_one_to_many_deletes_children_first() {
4193        let rt = RuntimeBuilder::current_thread()
4194            .build()
4195            .expect("create asupersync runtime");
4196        let cx = Cx::for_testing();
4197
4198        let state = Arc::new(Mutex::new(MockState::default()));
4199        let conn = MockConnection::new(Arc::clone(&state));
4200        let mut session = Session::with_config(
4201            conn,
4202            SessionConfig {
4203                auto_begin: false,
4204                auto_flush: false,
4205                expire_on_commit: true,
4206            },
4207        );
4208
4209        rt.block_on(async {
4210            // Load a parent so it's tracked as Persistent (MockConnection returns a row for id=1).
4211            let team = unwrap_outcome(session.get::<TeamWithHeroes>(&cx, 1_i64).await).unwrap();
4212
4213            // Load children into identity map via one-to-many batch loader.
4214            let mut teams = vec![team.clone()];
4215            let loaded = unwrap_outcome(
4216                session
4217                    .load_one_to_many::<TeamWithHeroes, HeroChild, _, _>(
4218                        &cx,
4219                        &mut teams,
4220                        |t| &mut t.heroes,
4221                        |t| t.id.map_or(Value::Null, Value::BigInt),
4222                    )
4223                    .await,
4224            );
4225            assert_eq!(loaded, 2);
4226
4227            // Mark parent for deletion and flush.
4228            session.delete(&team);
4229            unwrap_outcome(session.flush(&cx).await);
4230
4231            // Parent + children should be gone from the identity map after flush.
4232            assert_eq!(session.tracked_count(), 0);
4233        });
4234
4235        let guard = state.lock().expect("lock poisoned");
4236        assert!(
4237            guard.execute_calls >= 2,
4238            "expected at least cascade + parent delete"
4239        );
4240        let (sql0, _params0) = &guard.executed[0];
4241        let (sql1, _params1) = &guard.executed[1];
4242        assert!(
4243            sql0.contains("DELETE") && sql0.contains("heroes"),
4244            "expected first delete to target child table"
4245        );
4246        assert!(
4247            sql1.contains("DELETE") && sql1.contains("teams"),
4248            "expected second delete to target parent table"
4249        );
4250    }
4251
4252    #[test]
4253    fn test_flush_passive_deletes_does_not_emit_child_delete_but_detaches_children() {
4254        let rt = RuntimeBuilder::current_thread()
4255            .build()
4256            .expect("create asupersync runtime");
4257        let cx = Cx::for_testing();
4258
4259        let state = Arc::new(Mutex::new(MockState::default()));
4260        let conn = MockConnection::new(Arc::clone(&state));
4261        let mut session = Session::with_config(
4262            conn,
4263            SessionConfig {
4264                auto_begin: false,
4265                auto_flush: false,
4266                expire_on_commit: true,
4267            },
4268        );
4269
4270        rt.block_on(async {
4271            let team =
4272                unwrap_outcome(session.get::<TeamWithHeroesPassive>(&cx, 1_i64).await).unwrap();
4273
4274            // Load children into identity map.
4275            let mut teams = vec![team.clone()];
4276            let loaded = unwrap_outcome(
4277                session
4278                    .load_one_to_many::<TeamWithHeroesPassive, HeroChild, _, _>(
4279                        &cx,
4280                        &mut teams,
4281                        |t| &mut t.heroes,
4282                        |t| t.id.map_or(Value::Null, Value::BigInt),
4283                    )
4284                    .await,
4285            );
4286            assert_eq!(loaded, 2);
4287
4288            session.delete(&team);
4289            unwrap_outcome(session.flush(&cx).await);
4290
4291            assert_eq!(session.tracked_count(), 0);
4292        });
4293
4294        let guard = state.lock().expect("lock poisoned");
4295        assert_eq!(guard.execute_calls, 1, "expected only the parent delete");
4296        let (sql0, _params0) = &guard.executed[0];
4297        assert!(
4298            sql0.contains("teams_passive"),
4299            "expected delete to target parent table"
4300        );
4301        assert!(
4302            !sql0.contains("heroes"),
4303            "did not expect a child-table delete for passive_deletes"
4304        );
4305    }
4306
4307    #[test]
4308    fn test_flush_cascade_delete_composite_keys_deletes_children_first() {
4309        let rt = RuntimeBuilder::current_thread()
4310            .build()
4311            .expect("create asupersync runtime");
4312        let cx = Cx::for_testing();
4313
4314        let state = Arc::new(Mutex::new(MockState::default()));
4315        let conn = MockConnection::new(Arc::clone(&state));
4316        let mut session = Session::with_config(
4317            conn,
4318            SessionConfig {
4319                auto_begin: false,
4320                auto_flush: false,
4321                expire_on_commit: true,
4322            },
4323        );
4324
4325        let team = TeamComposite {
4326            id1: Some(1),
4327            id2: Some(2),
4328        };
4329        let team_key = ObjectKey::from_model(&team);
4330
4331        // Track parent as persistent.
4332        session.identity_map.insert(
4333            team_key,
4334            TrackedObject {
4335                object: Box::new(team.clone()),
4336                original_state: None,
4337                state: ObjectState::Persistent,
4338                table_name: TeamComposite::TABLE_NAME,
4339                column_names: vec!["id1", "id2"],
4340                values: vec![Value::BigInt(1), Value::BigInt(2)],
4341                pk_columns: vec!["id1", "id2"],
4342                pk_values: vec![Value::BigInt(1), Value::BigInt(2)],
4343                relationships: TeamComposite::RELATIONSHIPS,
4344                expired_attributes: None,
4345            },
4346        );
4347
4348        // Track two children that reference the parent via a composite FK.
4349        let child1 = HeroCompositeChild {
4350            id: Some(10),
4351            team_id1: 1,
4352            team_id2: 2,
4353        };
4354        let child2 = HeroCompositeChild {
4355            id: Some(11),
4356            team_id1: 1,
4357            team_id2: 2,
4358        };
4359        for child in [child1, child2] {
4360            let child_id = child.id.expect("child id");
4361            let key = ObjectKey::from_model(&child);
4362            session.identity_map.insert(
4363                key,
4364                TrackedObject {
4365                    object: Box::new(child),
4366                    original_state: None,
4367                    state: ObjectState::Persistent,
4368                    table_name: HeroCompositeChild::TABLE_NAME,
4369                    column_names: vec!["id", "team_id1", "team_id2"],
4370                    values: vec![Value::BigInt(child_id), Value::BigInt(1), Value::BigInt(2)],
4371                    pk_columns: vec!["id"],
4372                    pk_values: vec![Value::BigInt(child_id)],
4373                    relationships: HeroCompositeChild::RELATIONSHIPS,
4374                    expired_attributes: None,
4375                },
4376            );
4377        }
4378
4379        rt.block_on(async {
4380            session.delete(&team);
4381            unwrap_outcome(session.flush(&cx).await);
4382            assert_eq!(session.tracked_count(), 0);
4383        });
4384
4385        let guard = state.lock().expect("lock poisoned");
4386        assert!(
4387            guard.execute_calls >= 2,
4388            "expected at least composite cascade + parent delete"
4389        );
4390        let (sql0, _params0) = &guard.executed[0];
4391        assert!(sql0.contains("DELETE"), "expected DELETE SQL");
4392        assert!(
4393            sql0.contains("heroes_composite"),
4394            "expected composite cascade to target child table"
4395        );
4396        assert!(sql0.contains("team_id1"), "expected fk col team_id1");
4397        assert!(sql0.contains("team_id2"), "expected fk col team_id2");
4398        assert!(
4399            sql0.contains("$1") && sql0.contains("$2"),
4400            "expected Postgres-style placeholders for composite tuple"
4401        );
4402    }
4403
4404    #[test]
4405    fn test_flush_passive_deletes_composite_keys_detaches_children_no_child_delete_sql() {
4406        let rt = RuntimeBuilder::current_thread()
4407            .build()
4408            .expect("create asupersync runtime");
4409        let cx = Cx::for_testing();
4410
4411        let state = Arc::new(Mutex::new(MockState::default()));
4412        let conn = MockConnection::new(Arc::clone(&state));
4413        let mut session = Session::with_config(
4414            conn,
4415            SessionConfig {
4416                auto_begin: false,
4417                auto_flush: false,
4418                expire_on_commit: true,
4419            },
4420        );
4421
4422        let team = TeamCompositePassive {
4423            id1: Some(1),
4424            id2: Some(2),
4425        };
4426        let team_key = ObjectKey::from_model(&team);
4427
4428        session.identity_map.insert(
4429            team_key,
4430            TrackedObject {
4431                object: Box::new(team.clone()),
4432                original_state: None,
4433                state: ObjectState::Persistent,
4434                table_name: TeamCompositePassive::TABLE_NAME,
4435                column_names: vec!["id1", "id2"],
4436                values: vec![Value::BigInt(1), Value::BigInt(2)],
4437                pk_columns: vec!["id1", "id2"],
4438                pk_values: vec![Value::BigInt(1), Value::BigInt(2)],
4439                relationships: TeamCompositePassive::RELATIONSHIPS,
4440                expired_attributes: None,
4441            },
4442        );
4443
4444        let child = HeroCompositeChild {
4445            id: Some(10),
4446            team_id1: 1,
4447            team_id2: 2,
4448        };
4449        session.identity_map.insert(
4450            ObjectKey::from_model(&child),
4451            TrackedObject {
4452                object: Box::new(child),
4453                original_state: None,
4454                state: ObjectState::Persistent,
4455                table_name: HeroCompositeChild::TABLE_NAME,
4456                column_names: vec!["id", "team_id1", "team_id2"],
4457                values: vec![Value::BigInt(10), Value::BigInt(1), Value::BigInt(2)],
4458                pk_columns: vec!["id"],
4459                pk_values: vec![Value::BigInt(10)],
4460                relationships: HeroCompositeChild::RELATIONSHIPS,
4461                expired_attributes: None,
4462            },
4463        );
4464
4465        rt.block_on(async {
4466            session.delete(&team);
4467            unwrap_outcome(session.flush(&cx).await);
4468            assert_eq!(session.tracked_count(), 0);
4469        });
4470
4471        let guard = state.lock().expect("lock poisoned");
4472        assert_eq!(guard.execute_calls, 1, "expected only the parent delete");
4473        let (sql0, _params0) = &guard.executed[0];
4474        assert!(
4475            sql0.contains("teams_composite_passive"),
4476            "expected delete to target composite parent table"
4477        );
4478        assert!(
4479            !sql0.contains("heroes_composite"),
4480            "did not expect a child-table delete for passive_deletes"
4481        );
4482    }
4483
4484    #[derive(Debug, Clone, Serialize, Deserialize)]
4485    struct MmChildComposite {
4486        id1: i64,
4487        id2: i64,
4488    }
4489
4490    impl Model for MmChildComposite {
4491        const TABLE_NAME: &'static str = "mm_children";
4492        const PRIMARY_KEY: &'static [&'static str] = &["id1", "id2"];
4493
4494        fn fields() -> &'static [sqlmodel_core::FieldInfo] {
4495            &[]
4496        }
4497
4498        fn to_row(&self) -> Vec<(&'static str, Value)> {
4499            vec![
4500                ("id1", Value::BigInt(self.id1)),
4501                ("id2", Value::BigInt(self.id2)),
4502            ]
4503        }
4504
4505        fn from_row(_row: &Row) -> sqlmodel_core::Result<Self> {
4506            Ok(Self { id1: 0, id2: 0 })
4507        }
4508
4509        fn primary_key_value(&self) -> Vec<Value> {
4510            vec![Value::BigInt(self.id1), Value::BigInt(self.id2)]
4511        }
4512
4513        fn is_new(&self) -> bool {
4514            false
4515        }
4516    }
4517
4518    #[derive(Debug, Clone, Serialize, Deserialize)]
4519    struct MmParentComposite {
4520        id1: i64,
4521        id2: i64,
4522        children: sqlmodel_core::RelatedMany<MmChildComposite>,
4523    }
4524
4525    impl Model for MmParentComposite {
4526        const TABLE_NAME: &'static str = "mm_parents";
4527        const PRIMARY_KEY: &'static [&'static str] = &["id1", "id2"];
4528        const RELATIONSHIPS: &'static [sqlmodel_core::RelationshipInfo] =
4529            &[sqlmodel_core::RelationshipInfo::new(
4530                "children",
4531                MmChildComposite::TABLE_NAME,
4532                sqlmodel_core::RelationshipKind::ManyToMany,
4533            )
4534            .link_table(sqlmodel_core::LinkTableInfo::composite(
4535                "mm_link",
4536                &["parent_id1", "parent_id2"],
4537                &["child_id1", "child_id2"],
4538            ))
4539            .cascade_delete(true)];
4540
4541        fn fields() -> &'static [sqlmodel_core::FieldInfo] {
4542            &[]
4543        }
4544
4545        fn to_row(&self) -> Vec<(&'static str, Value)> {
4546            vec![
4547                ("id1", Value::BigInt(self.id1)),
4548                ("id2", Value::BigInt(self.id2)),
4549            ]
4550        }
4551
4552        fn from_row(_row: &Row) -> sqlmodel_core::Result<Self> {
4553            Ok(Self {
4554                id1: 0,
4555                id2: 0,
4556                children: sqlmodel_core::RelatedMany::with_link_table(
4557                    sqlmodel_core::LinkTableInfo::composite(
4558                        "mm_link",
4559                        &["parent_id1", "parent_id2"],
4560                        &["child_id1", "child_id2"],
4561                    ),
4562                ),
4563            })
4564        }
4565
4566        fn primary_key_value(&self) -> Vec<Value> {
4567            vec![Value::BigInt(self.id1), Value::BigInt(self.id2)]
4568        }
4569
4570        fn is_new(&self) -> bool {
4571            false
4572        }
4573    }
4574
4575    #[test]
4576    fn test_flush_cascade_delete_many_to_many_composite_parent_keys_deletes_link_rows_first() {
4577        let rt = RuntimeBuilder::current_thread()
4578            .build()
4579            .expect("create asupersync runtime");
4580        let cx = Cx::for_testing();
4581
4582        let state = Arc::new(Mutex::new(MockState::default()));
4583        let conn = MockConnection::new(Arc::clone(&state));
4584        let mut session = Session::with_config(
4585            conn,
4586            SessionConfig {
4587                auto_begin: false,
4588                auto_flush: false,
4589                expire_on_commit: true,
4590            },
4591        );
4592
4593        let parent = MmParentComposite {
4594            id1: 1,
4595            id2: 2,
4596            children: sqlmodel_core::RelatedMany::with_link_table(
4597                sqlmodel_core::LinkTableInfo::composite(
4598                    "mm_link",
4599                    &["parent_id1", "parent_id2"],
4600                    &["child_id1", "child_id2"],
4601                ),
4602            ),
4603        };
4604        let key = ObjectKey::from_model(&parent);
4605
4606        session.identity_map.insert(
4607            key,
4608            TrackedObject {
4609                object: Box::new(parent.clone()),
4610                original_state: None,
4611                state: ObjectState::Persistent,
4612                table_name: MmParentComposite::TABLE_NAME,
4613                column_names: vec!["id1", "id2"],
4614                values: vec![Value::BigInt(1), Value::BigInt(2)],
4615                pk_columns: vec!["id1", "id2"],
4616                pk_values: vec![Value::BigInt(1), Value::BigInt(2)],
4617                relationships: MmParentComposite::RELATIONSHIPS,
4618                expired_attributes: None,
4619            },
4620        );
4621
4622        rt.block_on(async {
4623            session.delete(&parent);
4624            unwrap_outcome(session.flush(&cx).await);
4625            assert_eq!(session.tracked_count(), 0);
4626        });
4627
4628        let guard = state.lock().expect("lock poisoned");
4629        assert!(
4630            guard.execute_calls >= 2,
4631            "expected at least link-table cascade + parent delete"
4632        );
4633        let (sql0, _params0) = &guard.executed[0];
4634        let (sql1, _params1) = &guard.executed[1];
4635        assert!(
4636            sql0.contains("DELETE") && sql0.contains("mm_link"),
4637            "expected first delete to target link table"
4638        );
4639        assert!(
4640            sql0.contains("parent_id1") && sql0.contains("parent_id2"),
4641            "expected composite local cols in link delete"
4642        );
4643        assert!(
4644            sql1.contains("DELETE") && sql1.contains("mm_parents"),
4645            "expected second delete to target parent table"
4646        );
4647    }
4648
4649    #[test]
4650    fn test_flush_related_many_composite_link_and_unlink() {
4651        let rt = RuntimeBuilder::current_thread()
4652            .build()
4653            .expect("create asupersync runtime");
4654        let cx = Cx::for_testing();
4655
4656        let state = Arc::new(Mutex::new(MockState::default()));
4657        let conn = MockConnection::new(Arc::clone(&state));
4658        let mut session = Session::with_config(
4659            conn,
4660            SessionConfig {
4661                auto_begin: false,
4662                auto_flush: false,
4663                expire_on_commit: true,
4664            },
4665        );
4666
4667        let link = sqlmodel_core::LinkTableInfo::composite(
4668            "mm_link",
4669            &["parent_id1", "parent_id2"],
4670            &["child_id1", "child_id2"],
4671        );
4672
4673        let mut parents = vec![MmParentComposite {
4674            id1: 1,
4675            id2: 2,
4676            children: sqlmodel_core::RelatedMany::with_link_table(link),
4677        }];
4678
4679        let child = MmChildComposite { id1: 7, id2: 9 };
4680
4681        parents[0].children.link(&child);
4682        parents[0].children.unlink(&child);
4683
4684        rt.block_on(async {
4685            let n = unwrap_outcome(
4686                session
4687                    .flush_related_many_pk::<MmParentComposite, MmChildComposite, _, _>(
4688                        &cx,
4689                        &mut parents,
4690                        |p| &mut p.children,
4691                        |p| vec![Value::BigInt(p.id1), Value::BigInt(p.id2)],
4692                        &link,
4693                    )
4694                    .await,
4695            );
4696            assert_eq!(n, 2);
4697        });
4698
4699        let guard = state.lock().expect("lock poisoned");
4700        assert_eq!(guard.execute_calls, 2);
4701        let (sql0, _params0) = &guard.executed[0];
4702        let (sql1, _params1) = &guard.executed[1];
4703
4704        assert!(sql0.contains("INSERT INTO"));
4705        assert!(sql0.contains("mm_link"));
4706        assert!(sql0.contains("parent_id1"));
4707        assert!(sql0.contains("parent_id2"));
4708        assert!(sql0.contains("child_id1"));
4709        assert!(sql0.contains("child_id2"));
4710        assert!(sql0.contains("$1") && sql0.contains("$4"));
4711
4712        assert!(sql1.contains("DELETE FROM"));
4713        assert!(sql1.contains("mm_link"));
4714        assert!(sql1.contains("parent_id1"));
4715        assert!(sql1.contains("child_id2"));
4716        assert!(sql1.contains("$1") && sql1.contains("$4"));
4717    }
4718
4719    #[test]
4720    fn test_load_many_to_many_pk_composite_builds_tuple_where_clause() {
4721        let rt = RuntimeBuilder::current_thread()
4722            .build()
4723            .expect("create asupersync runtime");
4724        let cx = Cx::for_testing();
4725
4726        let state = Arc::new(Mutex::new(MockState::default()));
4727        let conn = MockConnection::new(Arc::clone(&state));
4728        let mut session = Session::new(conn);
4729
4730        let link = sqlmodel_core::LinkTableInfo::composite(
4731            "mm_link",
4732            &["parent_id1", "parent_id2"],
4733            &["child_id1", "child_id2"],
4734        );
4735
4736        let mut parents = vec![MmParentComposite {
4737            id1: 1,
4738            id2: 2,
4739            children: sqlmodel_core::RelatedMany::with_link_table(link),
4740        }];
4741
4742        rt.block_on(async {
4743            let loaded = unwrap_outcome(
4744                session
4745                    .load_many_to_many_pk::<MmParentComposite, MmChildComposite, _, _>(
4746                        &cx,
4747                        &mut parents,
4748                        |p| &mut p.children,
4749                        |p| vec![Value::BigInt(p.id1), Value::BigInt(p.id2)],
4750                        &link,
4751                    )
4752                    .await,
4753            );
4754            assert_eq!(loaded, 0);
4755        });
4756
4757        let guard = state.lock().expect("lock poisoned");
4758        assert_eq!(guard.query_calls, 1);
4759        let sql = guard.last_sql.clone().expect("sql captured");
4760        assert!(sql.contains("JOIN"));
4761        assert!(sql.contains("mm_link"));
4762        assert!(sql.contains("WHERE"));
4763        assert!(sql.contains("parent_id1") && sql.contains("parent_id2"));
4764        assert!(sql.contains("IN (("), "expected tuple IN clause");
4765    }
4766
4767    #[test]
4768    fn test_add_all_with_vec() {
4769        let state = Arc::new(Mutex::new(MockState::default()));
4770        let conn = MockConnection::new(Arc::clone(&state));
4771        let mut session = Session::new(conn);
4772
4773        // Each object needs a unique PK for identity tracking
4774        // (objects without PKs get the same ObjectKey)
4775        let teams = vec![
4776            Team {
4777                id: Some(100),
4778                name: "Team A".to_string(),
4779            },
4780            Team {
4781                id: Some(101),
4782                name: "Team B".to_string(),
4783            },
4784            Team {
4785                id: Some(102),
4786                name: "Team C".to_string(),
4787            },
4788        ];
4789
4790        session.add_all(&teams);
4791
4792        let info = session.debug_state();
4793        assert_eq!(info.pending_new, 3);
4794        assert_eq!(info.tracked, 3);
4795    }
4796
4797    #[test]
4798    fn test_add_all_with_empty_collection() {
4799        let state = Arc::new(Mutex::new(MockState::default()));
4800        let conn = MockConnection::new(Arc::clone(&state));
4801        let mut session = Session::new(conn);
4802
4803        let teams: Vec<Team> = vec![];
4804        session.add_all(&teams);
4805
4806        let info = session.debug_state();
4807        assert_eq!(info.pending_new, 0);
4808        assert_eq!(info.tracked, 0);
4809    }
4810
4811    #[test]
4812    fn test_add_all_with_iterator() {
4813        let state = Arc::new(Mutex::new(MockState::default()));
4814        let conn = MockConnection::new(Arc::clone(&state));
4815        let mut session = Session::new(conn);
4816
4817        let teams = [
4818            Team {
4819                id: Some(200),
4820                name: "Team X".to_string(),
4821            },
4822            Team {
4823                id: Some(201),
4824                name: "Team Y".to_string(),
4825            },
4826        ];
4827
4828        // Use iter() explicitly
4829        session.add_all(teams.iter());
4830
4831        let info = session.debug_state();
4832        assert_eq!(info.pending_new, 2);
4833        assert_eq!(info.tracked, 2);
4834    }
4835
4836    #[test]
4837    fn test_add_all_with_slice() {
4838        let state = Arc::new(Mutex::new(MockState::default()));
4839        let conn = MockConnection::new(Arc::clone(&state));
4840        let mut session = Session::new(conn);
4841
4842        let teams = [
4843            Team {
4844                id: Some(300),
4845                name: "Team 1".to_string(),
4846            },
4847            Team {
4848                id: Some(301),
4849                name: "Team 2".to_string(),
4850            },
4851        ];
4852
4853        session.add_all(&teams);
4854
4855        let info = session.debug_state();
4856        assert_eq!(info.pending_new, 2);
4857        assert_eq!(info.tracked, 2);
4858    }
4859
4860    // ==================== Merge Tests ====================
4861
4862    #[test]
4863    fn test_merge_new_object_without_load() {
4864        let rt = RuntimeBuilder::current_thread()
4865            .build()
4866            .expect("create asupersync runtime");
4867        let cx = Cx::for_testing();
4868
4869        let state = Arc::new(Mutex::new(MockState::default()));
4870        let conn = MockConnection::new(Arc::clone(&state));
4871        let mut session = Session::new(conn);
4872
4873        rt.block_on(async {
4874            // Merge a new object without loading from DB
4875            let team = Team {
4876                id: Some(100),
4877                name: "New Team".to_string(),
4878            };
4879
4880            let merged = unwrap_outcome(session.merge(&cx, team.clone(), false).await);
4881
4882            // Should be the same object
4883            assert_eq!(merged.id, Some(100));
4884            assert_eq!(merged.name, "New Team");
4885
4886            // Should be tracked as new
4887            let info = session.debug_state();
4888            assert_eq!(info.pending_new, 1);
4889            assert_eq!(info.tracked, 1);
4890        });
4891
4892        // Should not have queried DB (load=false)
4893        assert_eq!(state.lock().expect("lock poisoned").query_calls, 0);
4894    }
4895
4896    #[test]
4897    fn test_merge_updates_existing_tracked_object() {
4898        let rt = RuntimeBuilder::current_thread()
4899            .build()
4900            .expect("create asupersync runtime");
4901        let cx = Cx::for_testing();
4902
4903        let state = Arc::new(Mutex::new(MockState::default()));
4904        let conn = MockConnection::new(Arc::clone(&state));
4905        let mut session = Session::new(conn);
4906
4907        rt.block_on(async {
4908            // First add an object
4909            let original = Team {
4910                id: Some(1),
4911                name: "Original".to_string(),
4912            };
4913            session.add(&original);
4914
4915            // Now merge an updated version
4916            let updated = Team {
4917                id: Some(1),
4918                name: "Updated".to_string(),
4919            };
4920
4921            let merged = unwrap_outcome(session.merge(&cx, updated, false).await);
4922
4923            // Should have the updated name
4924            assert_eq!(merged.id, Some(1));
4925            assert_eq!(merged.name, "Updated");
4926
4927            // Should still be tracked (not duplicated)
4928            let info = session.debug_state();
4929            assert_eq!(info.tracked, 1);
4930        });
4931    }
4932
4933    #[test]
4934    fn test_merge_with_load_queries_database() {
4935        let rt = RuntimeBuilder::current_thread()
4936            .build()
4937            .expect("create asupersync runtime");
4938        let cx = Cx::for_testing();
4939
4940        let state = Arc::new(Mutex::new(MockState::default()));
4941        let conn = MockConnection::new(Arc::clone(&state));
4942        let mut session = Session::new(conn);
4943
4944        rt.block_on(async {
4945            // Merge an object that exists in the "database" (mock returns it for id=1)
4946            let detached = Team {
4947                id: Some(1),
4948                name: "Detached Update".to_string(),
4949            };
4950
4951            let merged = unwrap_outcome(session.merge(&cx, detached, true).await);
4952
4953            // Should have the name from our detached object (merged onto DB values)
4954            assert_eq!(merged.id, Some(1));
4955            assert_eq!(merged.name, "Detached Update");
4956
4957            // Should be tracked and marked as dirty
4958            let info = session.debug_state();
4959            assert_eq!(info.tracked, 1);
4960            assert_eq!(info.pending_dirty, 1);
4961        });
4962
4963        // Should have queried DB once (load=true)
4964        assert_eq!(state.lock().expect("lock poisoned").query_calls, 1);
4965    }
4966
4967    #[test]
4968    fn test_merge_with_load_not_found_creates_new() {
4969        let rt = RuntimeBuilder::current_thread()
4970            .build()
4971            .expect("create asupersync runtime");
4972        let cx = Cx::for_testing();
4973
4974        let state = Arc::new(Mutex::new(MockState::default()));
4975        let conn = MockConnection::new(Arc::clone(&state));
4976        let mut session = Session::new(conn);
4977
4978        rt.block_on(async {
4979            // Merge an object that doesn't exist in DB (mock returns None for id=999)
4980            let detached = Team {
4981                id: Some(999),
4982                name: "Not In DB".to_string(),
4983            };
4984
4985            let merged = unwrap_outcome(session.merge(&cx, detached, true).await);
4986
4987            // Should keep the values we provided
4988            assert_eq!(merged.id, Some(999));
4989            assert_eq!(merged.name, "Not In DB");
4990
4991            // Should be tracked as new
4992            let info = session.debug_state();
4993            assert_eq!(info.pending_new, 1);
4994            assert_eq!(info.tracked, 1);
4995        });
4996
4997        // Should have queried DB once
4998        assert_eq!(state.lock().expect("lock poisoned").query_calls, 1);
4999    }
5000
5001    #[test]
5002    fn test_merge_without_load_convenience() {
5003        let rt = RuntimeBuilder::current_thread()
5004            .build()
5005            .expect("create asupersync runtime");
5006        let cx = Cx::for_testing();
5007
5008        let state = Arc::new(Mutex::new(MockState::default()));
5009        let conn = MockConnection::new(Arc::clone(&state));
5010        let mut session = Session::new(conn);
5011
5012        rt.block_on(async {
5013            let team = Team {
5014                id: Some(42),
5015                name: "Test".to_string(),
5016            };
5017
5018            // Use the convenience method
5019            let merged = unwrap_outcome(session.merge_without_load(&cx, team).await);
5020
5021            assert_eq!(merged.id, Some(42));
5022            assert_eq!(merged.name, "Test");
5023
5024            let info = session.debug_state();
5025            assert_eq!(info.pending_new, 1);
5026        });
5027
5028        // Should not have queried DB
5029        assert_eq!(state.lock().expect("lock poisoned").query_calls, 0);
5030    }
5031
5032    #[test]
5033    fn test_merge_null_pk_treated_as_new() {
5034        let rt = RuntimeBuilder::current_thread()
5035            .build()
5036            .expect("create asupersync runtime");
5037        let cx = Cx::for_testing();
5038
5039        let state = Arc::new(Mutex::new(MockState::default()));
5040        let conn = MockConnection::new(Arc::clone(&state));
5041        let mut session = Session::new(conn);
5042
5043        rt.block_on(async {
5044            // Merge object with null PK (new record)
5045            let new_team = Team {
5046                id: None,
5047                name: "Brand New".to_string(),
5048            };
5049
5050            let merged = unwrap_outcome(session.merge(&cx, new_team, true).await);
5051
5052            // Should keep the null id
5053            assert_eq!(merged.id, None);
5054            assert_eq!(merged.name, "Brand New");
5055
5056            // Should be tracked as new (no DB query for null PK)
5057            let info = session.debug_state();
5058            assert_eq!(info.pending_new, 1);
5059        });
5060
5061        // Should not have queried DB for null PK
5062        assert_eq!(state.lock().expect("lock poisoned").query_calls, 0);
5063    }
5064
5065    // ==================== is_modified Tests ====================
5066
5067    #[test]
5068    fn test_is_modified_new_object_returns_true() {
5069        let state = Arc::new(Mutex::new(MockState::default()));
5070        let conn = MockConnection::new(Arc::clone(&state));
5071        let mut session = Session::new(conn);
5072
5073        let team = Team {
5074            id: Some(100),
5075            name: "New Team".to_string(),
5076        };
5077
5078        // Add as new - should be modified
5079        session.add(&team);
5080        assert!(session.is_modified(&team));
5081    }
5082
5083    #[test]
5084    fn test_is_modified_untracked_returns_false() {
5085        let state = Arc::new(Mutex::new(MockState::default()));
5086        let conn = MockConnection::new(Arc::clone(&state));
5087        let session = Session::<MockConnection>::new(conn);
5088
5089        let team = Team {
5090            id: Some(100),
5091            name: "Not Tracked".to_string(),
5092        };
5093
5094        // Not tracked - should not be modified
5095        assert!(!session.is_modified(&team));
5096    }
5097
5098    #[test]
5099    fn test_is_modified_after_load_returns_false() {
5100        let rt = RuntimeBuilder::current_thread()
5101            .build()
5102            .expect("create asupersync runtime");
5103        let cx = Cx::for_testing();
5104
5105        let state = Arc::new(Mutex::new(MockState::default()));
5106        let conn = MockConnection::new(Arc::clone(&state));
5107        let mut session = Session::new(conn);
5108
5109        rt.block_on(async {
5110            // Load from DB
5111            let team = unwrap_outcome(session.get::<Team>(&cx, 1_i64).await).unwrap();
5112
5113            // Fresh from DB - should not be modified
5114            assert!(!session.is_modified(&team));
5115        });
5116    }
5117
5118    #[test]
5119    fn test_is_modified_after_mark_dirty_returns_true() {
5120        let rt = RuntimeBuilder::current_thread()
5121            .build()
5122            .expect("create asupersync runtime");
5123        let cx = Cx::for_testing();
5124
5125        let state = Arc::new(Mutex::new(MockState::default()));
5126        let conn = MockConnection::new(Arc::clone(&state));
5127        let mut session = Session::new(conn);
5128
5129        rt.block_on(async {
5130            // Load from DB
5131            let team = unwrap_outcome(session.get::<Team>(&cx, 1_i64).await).unwrap();
5132            assert!(!session.is_modified(&team));
5133
5134            // Modify and mark dirty
5135            let mut modified_team = team.clone();
5136            modified_team.name = "Modified Name".to_string();
5137            session.mark_dirty(&modified_team);
5138
5139            // Should now be modified
5140            assert!(session.is_modified(&modified_team));
5141        });
5142    }
5143
5144    #[test]
5145    fn test_is_modified_deleted_returns_true() {
5146        let rt = RuntimeBuilder::current_thread()
5147            .build()
5148            .expect("create asupersync runtime");
5149        let cx = Cx::for_testing();
5150
5151        let state = Arc::new(Mutex::new(MockState::default()));
5152        let conn = MockConnection::new(Arc::clone(&state));
5153        let mut session = Session::new(conn);
5154
5155        rt.block_on(async {
5156            // Load from DB
5157            let team = unwrap_outcome(session.get::<Team>(&cx, 1_i64).await).unwrap();
5158            assert!(!session.is_modified(&team));
5159
5160            // Delete
5161            session.delete(&team);
5162
5163            // Should be modified (pending delete)
5164            assert!(session.is_modified(&team));
5165        });
5166    }
5167
5168    #[test]
5169    fn test_is_modified_detached_returns_false() {
5170        let rt = RuntimeBuilder::current_thread()
5171            .build()
5172            .expect("create asupersync runtime");
5173        let cx = Cx::for_testing();
5174
5175        let state = Arc::new(Mutex::new(MockState::default()));
5176        let conn = MockConnection::new(Arc::clone(&state));
5177        let mut session = Session::new(conn);
5178
5179        rt.block_on(async {
5180            // Load from DB
5181            let team = unwrap_outcome(session.get::<Team>(&cx, 1_i64).await).unwrap();
5182
5183            // Detach
5184            session.expunge(&team);
5185
5186            // Detached objects aren't modified in session context
5187            assert!(!session.is_modified(&team));
5188        });
5189    }
5190
5191    #[test]
5192    fn test_object_state_returns_correct_state() {
5193        let rt = RuntimeBuilder::current_thread()
5194            .build()
5195            .expect("create asupersync runtime");
5196        let cx = Cx::for_testing();
5197
5198        let state = Arc::new(Mutex::new(MockState::default()));
5199        let conn = MockConnection::new(Arc::clone(&state));
5200        let mut session = Session::new(conn);
5201
5202        // Untracked object
5203        let untracked = Team {
5204            id: Some(999),
5205            name: "Untracked".to_string(),
5206        };
5207        assert_eq!(session.object_state(&untracked), None);
5208
5209        // New object
5210        let new_team = Team {
5211            id: Some(100),
5212            name: "New".to_string(),
5213        };
5214        session.add(&new_team);
5215        assert_eq!(session.object_state(&new_team), Some(ObjectState::New));
5216
5217        rt.block_on(async {
5218            // Persistent object
5219            let persistent = unwrap_outcome(session.get::<Team>(&cx, 1_i64).await).unwrap();
5220            assert_eq!(
5221                session.object_state(&persistent),
5222                Some(ObjectState::Persistent)
5223            );
5224
5225            // Deleted object
5226            session.delete(&persistent);
5227            assert_eq!(
5228                session.object_state(&persistent),
5229                Some(ObjectState::Deleted)
5230            );
5231        });
5232    }
5233
5234    #[test]
5235    fn test_modified_attributes_returns_changed_columns() {
5236        let rt = RuntimeBuilder::current_thread()
5237            .build()
5238            .expect("create asupersync runtime");
5239        let cx = Cx::for_testing();
5240
5241        let state = Arc::new(Mutex::new(MockState::default()));
5242        let conn = MockConnection::new(Arc::clone(&state));
5243        let mut session = Session::new(conn);
5244
5245        rt.block_on(async {
5246            // Load from DB
5247            let team = unwrap_outcome(session.get::<Team>(&cx, 1_i64).await).unwrap();
5248
5249            // No modifications yet
5250            let modified = session.modified_attributes(&team);
5251            assert!(modified.is_empty());
5252
5253            // Modify and mark dirty
5254            let mut modified_team = team.clone();
5255            modified_team.name = "Changed Name".to_string();
5256            session.mark_dirty(&modified_team);
5257
5258            // Should show 'name' as modified
5259            let modified = session.modified_attributes(&modified_team);
5260            assert!(modified.contains(&"name"));
5261        });
5262    }
5263
5264    #[test]
5265    fn test_modified_attributes_untracked_returns_empty() {
5266        let state = Arc::new(Mutex::new(MockState::default()));
5267        let conn = MockConnection::new(Arc::clone(&state));
5268        let session = Session::<MockConnection>::new(conn);
5269
5270        let team = Team {
5271            id: Some(100),
5272            name: "Not Tracked".to_string(),
5273        };
5274
5275        let modified = session.modified_attributes(&team);
5276        assert!(modified.is_empty());
5277    }
5278
5279    #[test]
5280    fn test_modified_attributes_new_returns_empty() {
5281        let state = Arc::new(Mutex::new(MockState::default()));
5282        let conn = MockConnection::new(Arc::clone(&state));
5283        let mut session = Session::new(conn);
5284
5285        let team = Team {
5286            id: Some(100),
5287            name: "New".to_string(),
5288        };
5289        session.add(&team);
5290
5291        // New objects don't have original values to compare
5292        let modified = session.modified_attributes(&team);
5293        assert!(modified.is_empty());
5294    }
5295
5296    // ==================== Expire Tests ====================
5297
5298    #[test]
5299    fn test_expire_marks_object_as_expired() {
5300        let rt = RuntimeBuilder::current_thread()
5301            .build()
5302            .expect("create asupersync runtime");
5303        let cx = Cx::for_testing();
5304
5305        let state = Arc::new(Mutex::new(MockState::default()));
5306        let conn = MockConnection::new(Arc::clone(&state));
5307        let mut session = Session::new(conn);
5308
5309        rt.block_on(async {
5310            // Get an object from DB (creates Persistent state)
5311            let team = unwrap_outcome(session.get::<Team>(&cx, 1_i64).await);
5312            assert!(team.is_some());
5313            let team = team.unwrap();
5314
5315            // Verify it's not expired initially
5316            assert!(!session.is_expired(&team));
5317            assert_eq!(session.object_state(&team), Some(ObjectState::Persistent));
5318
5319            // Expire all attributes
5320            session.expire(&team, None);
5321
5322            // Should now be expired
5323            assert!(session.is_expired(&team));
5324            assert_eq!(session.object_state(&team), Some(ObjectState::Expired));
5325        });
5326    }
5327
5328    #[test]
5329    fn test_expire_specific_attributes() {
5330        let rt = RuntimeBuilder::current_thread()
5331            .build()
5332            .expect("create asupersync runtime");
5333        let cx = Cx::for_testing();
5334
5335        let state = Arc::new(Mutex::new(MockState::default()));
5336        let conn = MockConnection::new(Arc::clone(&state));
5337        let mut session = Session::new(conn);
5338
5339        rt.block_on(async {
5340            // Get an object from DB
5341            let team = unwrap_outcome(session.get::<Team>(&cx, 1_i64).await).unwrap();
5342
5343            // Expire specific attributes
5344            session.expire(&team, Some(&["name"]));
5345
5346            // Should be expired
5347            assert!(session.is_expired(&team));
5348
5349            // Check expired attributes
5350            let expired = session.expired_attributes(&team);
5351            assert!(expired.is_some());
5352            let expired_set = expired.unwrap();
5353            assert!(expired_set.is_some());
5354            assert!(expired_set.unwrap().contains("name"));
5355        });
5356    }
5357
5358    #[test]
5359    fn test_expire_all_marks_all_objects_expired() {
5360        let rt = RuntimeBuilder::current_thread()
5361            .build()
5362            .expect("create asupersync runtime");
5363        let cx = Cx::for_testing();
5364
5365        let state = Arc::new(Mutex::new(MockState::default()));
5366        let conn = MockConnection::new(Arc::clone(&state));
5367        let mut session = Session::new(conn);
5368
5369        rt.block_on(async {
5370            // Get multiple objects from DB
5371            let team1 = unwrap_outcome(session.get::<Team>(&cx, 1_i64).await).unwrap();
5372            let team2 = unwrap_outcome(session.get::<Team>(&cx, 2_i64).await).unwrap();
5373
5374            // Verify neither is expired
5375            assert!(!session.is_expired(&team1));
5376            assert!(!session.is_expired(&team2));
5377
5378            // Expire all
5379            session.expire_all();
5380
5381            // Both should be expired
5382            assert!(session.is_expired(&team1));
5383            assert!(session.is_expired(&team2));
5384        });
5385    }
5386
5387    #[test]
5388    fn test_expire_does_not_affect_new_objects() {
5389        let state = Arc::new(Mutex::new(MockState::default()));
5390        let conn = MockConnection::new(Arc::clone(&state));
5391        let mut session = Session::new(conn);
5392
5393        // Add a new object
5394        let team = Team {
5395            id: Some(100),
5396            name: "New Team".to_string(),
5397        };
5398        session.add(&team);
5399
5400        // Try to expire it
5401        session.expire(&team, None);
5402
5403        // Should still be New, not Expired
5404        assert_eq!(session.object_state(&team), Some(ObjectState::New));
5405        assert!(!session.is_expired(&team));
5406    }
5407
5408    #[test]
5409    fn test_expired_object_reloads_on_get() {
5410        let rt = RuntimeBuilder::current_thread()
5411            .build()
5412            .expect("create asupersync runtime");
5413        let cx = Cx::for_testing();
5414
5415        let state = Arc::new(Mutex::new(MockState::default()));
5416        let conn = MockConnection::new(Arc::clone(&state));
5417        let mut session = Session::new(conn);
5418
5419        rt.block_on(async {
5420            // Get an object (query 1)
5421            let team = unwrap_outcome(session.get::<Team>(&cx, 1_i64).await).unwrap();
5422            assert_eq!(team.name, "Avengers");
5423
5424            // Get again - should use cache (no additional query)
5425            let team2 = unwrap_outcome(session.get::<Team>(&cx, 1_i64).await).unwrap();
5426            assert_eq!(team2.name, "Avengers");
5427
5428            // Verify only 1 query so far
5429            {
5430                let s = state.lock().expect("lock poisoned");
5431                assert_eq!(s.query_calls, 1);
5432            }
5433
5434            // Expire the object
5435            session.expire(&team, None);
5436
5437            // Get again - should reload from DB (query 2)
5438            let team3 = unwrap_outcome(session.get::<Team>(&cx, 1_i64).await).unwrap();
5439            assert_eq!(team3.name, "Avengers");
5440
5441            // Verify a second query was made
5442            {
5443                let s = state.lock().expect("lock poisoned");
5444                assert_eq!(s.query_calls, 2);
5445            }
5446
5447            // Should no longer be expired after reload
5448            assert!(!session.is_expired(&team3));
5449            assert_eq!(session.object_state(&team3), Some(ObjectState::Persistent));
5450        });
5451    }
5452
5453    #[test]
5454    fn test_is_expired_returns_false_for_untracked() {
5455        let state = Arc::new(Mutex::new(MockState::default()));
5456        let conn = MockConnection::new(Arc::clone(&state));
5457        let session = Session::<MockConnection>::new(conn);
5458
5459        let team = Team {
5460            id: Some(999),
5461            name: "Not Tracked".to_string(),
5462        };
5463
5464        // Should return false for untracked objects
5465        assert!(!session.is_expired(&team));
5466    }
5467
5468    #[test]
5469    fn test_expired_attributes_returns_none_for_persistent() {
5470        let rt = RuntimeBuilder::current_thread()
5471            .build()
5472            .expect("create asupersync runtime");
5473        let cx = Cx::for_testing();
5474
5475        let state = Arc::new(Mutex::new(MockState::default()));
5476        let conn = MockConnection::new(Arc::clone(&state));
5477        let mut session = Session::new(conn);
5478
5479        rt.block_on(async {
5480            // Get an object (Persistent state)
5481            let team = unwrap_outcome(session.get::<Team>(&cx, 1_i64).await).unwrap();
5482
5483            // Should return None for non-expired objects
5484            let expired = session.expired_attributes(&team);
5485            assert!(expired.is_none());
5486        });
5487    }
5488}