Skip to main content

sql_orm/
tracking.rs

1//! Experimental change tracking surface.
2//!
3//! Stability audit status, 2026-04-30: this is the only root-crate public
4//! surface still explicitly marked experimental. It remains implemented but
5//! not stable until the context owns tracked snapshots instead of depending on
6//! live wrappers. The current slice has validated identity registration,
7//! explicit state APIs, no-op change detection, operation ordering,
8//! transaction behavior for direct connections, policy integration and public
9//! compile-time coverage, but ownership remains the blocking contract.
10//!
11//! This module intentionally defines only the minimal public contracts for the
12//! future tracking pipeline. In this stage it does not:
13//! - replace the explicit `DbSet`/`ActiveRecord` APIs
14//! - infer inserts, updates or deletes globally outside of `Tracked<T>`
15//! - keep dropped wrappers in the unit of work
16//! - support composite primary keys through `save_changes()`; that limit is
17//!   now an explicit first-stable-cut scope rather than an implicit behavior
18//!
19//! Current experimental entry points:
20//! - `DbSet::find_tracked(id)` for existing entities with single-column PK
21//! - `DbSet::add_tracked(entity)` for new entities pending insertion
22//! - `DbSet::remove_tracked(&mut tracked)` for explicit tracked deletion
23//! - `Tracked::mark_modified()`, `Tracked::mark_deleted()`,
24//!   `Tracked::mark_unchanged()` and `Tracked::detach()` for explicit state
25//!   transitions on a wrapper
26//! - `DbContext::save_changes()` for explicit persistence of live wrappers
27//!
28//! Observable limits in the current stage:
29//! - only wrappers still alive participate in `save_changes()`
30//! - mutable access marks `Unchanged` entities as `Modified` immediately
31//! - loaded entities are registered with a deterministic identity made from
32//!   entity type, schema, table and single-column primary key value
33//! - registering the same loaded entity identity twice in one context returns
34//!   an `OrmError` instead of keeping silent duplicates
35//! - added entities use temporary local identities until a successful insert
36//!   returns their persisted primary key
37//! - explicit detach removes an entry from the registry without touching the
38//!   database
39//! - clearing the tracker removes every current registry entry
40//! - dropping a wrapper is still equivalent to detach in this experimental
41//!   pointer-backed slice
42//! - removing a tracked `Added` entity cancels the pending insert locally
43//! - successful tracked deletes unregister the wrapper from the internal registry
44//! - rowversion conflicts are still surfaced as `OrmError::ConcurrencyConflict`
45//! - composite-primary-key entities fail with a stable `OrmError` when loaded
46//!   through `find_tracked(...)` or persisted through `save_changes()`; the
47//!   first stable cut intentionally keeps tracking persistence scoped to
48//!   single-column primary keys
49//! - `tracked.save(&db).await` and `tracked.delete(&db).await` have explicit
50//!   wrapper semantics, so they do not dereference to Active Record and leave
51//!   stale tracker state behind
52//! - navigation includes and explicit navigation loads attach values to the
53//!   root entity only; related entities are not automatically registered in the
54//!   experimental tracker and relationship changes are not persisted as graph
55//!   updates
56//! - future relationship persistence is intentionally deferred until graph
57//!   update semantics can define dependent insert/delete behavior, foreign-key
58//!   updates, direct many-to-many exclusions and conflict handling without
59//!   bypassing the existing `DbSet` persistence paths
60
61use crate::EntityPersist;
62use core::ops::{Deref, DerefMut};
63use sql_orm_core::{Entity, EntityMetadata, OrmError, SqlValue};
64use std::any::TypeId;
65use std::marker::PhantomData;
66use std::sync::{Arc, Mutex};
67
68/// Lifecycle state for an experimentally tracked entity.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum EntityState {
71    /// Entity was loaded and has not requested mutable access.
72    Unchanged,
73    /// Entity was added locally and should be inserted by `save_changes()`.
74    Added,
75    /// Entity was loaded and then mutably accessed.
76    Modified,
77    /// Entity was explicitly marked for deletion.
78    Deleted,
79}
80
81/// Snapshot-based wrapper for entities tracked experimentally.
82///
83/// `Tracked<T>` keeps the original snapshot together with the current value so
84/// later stages can compare and persist changes without relying on runtime
85/// proxies or reflection. The current implementation is still wrapper-backed:
86/// dropping or consuming a registered wrapper removes it from the current
87/// context tracker. Cloning a wrapper copies its visible state and snapshots,
88/// but the clone is detached from the registry.
89/// Calling [`Tracked::detach`] repeatedly is a no-op after the first detach and
90/// does not reset the visible wrapper state.
91///
92/// State can be inspected with [`Tracked::state`] and changed explicitly with
93/// [`Tracked::mark_modified`], [`Tracked::mark_deleted`],
94/// [`Tracked::mark_unchanged`] and [`Tracked::detach`]. Immediate
95/// persistence through [`Tracked::save`] and [`Tracked::delete`] delegates to
96/// the same `DbSet`/Active Record pipelines used by ordinary CRUD.
97pub struct Tracked<T> {
98    inner: Box<TrackedInner<T>>,
99    registration_id: Option<usize>,
100    tracking_registry: Option<TrackingRegistryHandle>,
101}
102
103#[doc(hidden)]
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct TrackedEntityRegistration {
106    pub entity_rust_name: &'static str,
107    pub state: EntityState,
108}
109
110#[doc(hidden)]
111#[derive(Debug, Default)]
112pub struct TrackingRegistry {
113    state: Mutex<TrackingRegistryState>,
114}
115
116#[doc(hidden)]
117pub type TrackingRegistryHandle = Arc<TrackingRegistry>;
118
119#[doc(hidden)]
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct SaveChangesOperationPlan {
122    added_order: Vec<usize>,
123    modified_order: Vec<usize>,
124    deleted_order: Vec<usize>,
125}
126
127struct TrackedInner<T> {
128    original: T,
129    current: T,
130    state: EntityState,
131}
132
133#[derive(Debug, Default)]
134struct TrackingRegistryState {
135    next_registration_id: usize,
136    next_temporary_identity: u64,
137    entries: Vec<TrackingRegistration>,
138}
139
140#[derive(Debug)]
141struct TrackingRegistration {
142    registration_id: usize,
143    identity: TrackedIdentity,
144    entity_type_id: TypeId,
145    entity_rust_name: &'static str,
146    inner_address: usize,
147    state_reader: unsafe fn(*const ()) -> EntityState,
148}
149
150#[derive(Debug, Clone, PartialEq)]
151struct TrackedIdentity {
152    entity_type_id: TypeId,
153    entity_rust_name: &'static str,
154    schema: &'static str,
155    table: &'static str,
156    primary_key: TrackedPrimaryKeyIdentity,
157}
158
159#[derive(Debug, Clone, PartialEq)]
160enum TrackedPrimaryKeyIdentity {
161    Simple(SqlValue),
162    Temporary(u64),
163}
164
165#[derive(Clone, Copy)]
166pub(crate) struct RegisteredTracked<E> {
167    registration_id: usize,
168    inner_address: usize,
169    _entity: PhantomData<fn() -> E>,
170}
171
172impl<T: Clone> Tracked<T> {
173    /// Creates a tracked value loaded from persistence.
174    pub fn from_loaded(entity: T) -> Self {
175        Self {
176            inner: Box::new(TrackedInner {
177                original: entity.clone(),
178                current: entity,
179                state: EntityState::Unchanged,
180            }),
181            registration_id: None,
182            tracking_registry: None,
183        }
184    }
185
186    /// Creates a tracked value that represents a new entity pending insertion.
187    pub fn from_added(entity: T) -> Self {
188        Self {
189            inner: Box::new(TrackedInner {
190                original: entity.clone(),
191                current: entity,
192                state: EntityState::Added,
193            }),
194            registration_id: None,
195            tracking_registry: None,
196        }
197    }
198}
199
200impl<T> Tracked<T> {
201    /// Returns the original snapshot captured when tracking started.
202    pub fn original(&self) -> &T {
203        &self.inner.original
204    }
205
206    /// Returns the current in-memory value.
207    pub fn current(&self) -> &T {
208        &self.inner.current
209    }
210
211    /// Returns the current tracking state.
212    pub const fn state(&self) -> EntityState {
213        self.inner.state
214    }
215
216    /// Explicitly marks this tracked value as `Modified`.
217    ///
218    /// `Added` values remain `Added` because they still need an insert, and
219    /// `Deleted` values remain `Deleted` because deletion wins over pending
220    /// modifications until the caller explicitly marks the value unchanged.
221    pub fn mark_modified(&mut self) {
222        self.mark_modified_if_unchanged();
223    }
224
225    /// Explicitly marks this tracked value as `Deleted`.
226    ///
227    /// This is a state transition only; it does not execute SQL. Calling it
228    /// for an `Added` wrapper cancels the pending local insert by detaching the
229    /// wrapper from the tracker.
230    pub fn mark_deleted(&mut self) {
231        let was_added = self.inner.state == EntityState::Added;
232        self.inner.state = EntityState::Deleted;
233        if was_added {
234            self.detach_registry();
235        }
236    }
237
238    /// Explicitly accepts the current in-memory value as unchanged.
239    ///
240    /// The current value becomes the new original snapshot and later
241    /// `save_changes()` calls ignore this wrapper until it is marked or
242    /// mutably accessed again.
243    pub fn mark_unchanged(&mut self)
244    where
245        T: Clone,
246    {
247        self.inner.original = self.inner.current.clone();
248        self.inner.state = EntityState::Unchanged;
249    }
250
251    /// Detaches this wrapper from its context tracker without executing SQL.
252    ///
253    /// Detach removes the registration from the current context unit of work
254    /// and leaves the visible wrapper state unchanged.
255    pub fn detach(&mut self) {
256        self.detach_registry();
257    }
258
259    /// Returns mutable access to the current value and marks the entity as
260    /// modified when it was previously loaded as unchanged.
261    pub fn current_mut(&mut self) -> &mut T {
262        self.mark_modified_if_unchanged();
263        &mut self.inner.current
264    }
265
266    pub(crate) fn current_mut_without_state_change(&mut self) -> &mut T {
267        &mut self.inner.current
268    }
269
270    fn mark_modified_if_unchanged(&mut self) {
271        if self.inner.state == EntityState::Unchanged {
272            self.inner.state = EntityState::Modified;
273        }
274    }
275
276    pub(crate) fn detach_registry(&mut self) {
277        if let (Some(registration_id), Some(registry)) =
278            (self.registration_id.take(), self.tracking_registry.take())
279        {
280            registry.unregister(registration_id);
281        }
282    }
283
284    /// Persists this tracked entity immediately through the Active Record
285    /// pipeline and synchronizes the tracking snapshot after success.
286    ///
287    /// This method exists so `tracked.save(&db).await` has explicit tracking
288    /// semantics instead of dereferencing to `T::save(&db)` and leaving the
289    /// tracker with a stale original snapshot. `Unchanged` wrappers are a
290    /// no-op, `Added` and `Modified` wrappers use the same persistence path as
291    /// Active Record, and `Deleted` wrappers return an error.
292    pub fn save<C>(
293        &mut self,
294        db: &C,
295    ) -> impl core::future::Future<Output = Result<(), OrmError>> + Send
296    where
297        C: crate::DbContextEntitySet<T> + Sync,
298        T: crate::ActiveRecord
299            + crate::AuditEntity
300            + crate::EntityPersist
301            + crate::EntityPrimaryKey
302            + crate::SoftDeleteEntity
303            + crate::TenantScopedEntity
304            + Clone
305            + sql_orm_core::FromRow
306            + Send,
307    {
308        async move {
309            match self.inner.state {
310                EntityState::Unchanged => Ok(()),
311                EntityState::Deleted => Err(OrmError::new(
312                    "tracked deleted entities cannot be saved; detach them or persist deletion",
313                )),
314                EntityState::Added | EntityState::Modified => {
315                    crate::ActiveRecord::save(&mut self.inner.current, db).await?;
316                    self.inner.original = self.inner.current.clone();
317                    self.inner.state = EntityState::Unchanged;
318
319                    if let (Some(registration_id), Some(registry)) =
320                        (self.registration_id, self.tracking_registry.as_ref())
321                    {
322                        let key =
323                            <T as crate::EntityPrimaryKey>::primary_key_value(&self.inner.current)?;
324                        registry.update_persisted_identity::<T>(registration_id, key)?;
325                    }
326
327                    Ok(())
328                }
329            }
330        }
331    }
332
333    /// Deletes this tracked entity immediately through the Active Record
334    /// pipeline and removes it from the context tracker after success.
335    ///
336    /// Calling `tracked.delete(&db).await` on an `Added` wrapper cancels the
337    /// local insert without touching the database. Persisted wrappers delegate
338    /// to Active Record delete and detach after the row is affected, so a later
339    /// `save_changes()` will not issue a second delete for the same wrapper.
340    pub fn delete<C>(
341        &mut self,
342        db: &C,
343    ) -> impl core::future::Future<Output = Result<bool, OrmError>> + Send
344    where
345        C: crate::DbContextEntitySet<T> + Sync,
346        T: crate::ActiveRecord
347            + crate::EntityPersist
348            + crate::EntityPrimaryKey
349            + crate::SoftDeleteEntity
350            + crate::TenantScopedEntity
351            + Clone
352            + sql_orm_core::FromRow
353            + Send,
354    {
355        async move {
356            match self.inner.state {
357                EntityState::Added => {
358                    self.inner.state = EntityState::Deleted;
359                    self.detach_registry();
360                    Ok(false)
361                }
362                EntityState::Deleted => Ok(false),
363                EntityState::Unchanged | EntityState::Modified => {
364                    let deleted = crate::ActiveRecord::delete(&self.inner.current, db).await?;
365                    if deleted {
366                        self.inner.state = EntityState::Deleted;
367                        self.detach_registry();
368                    }
369                    Ok(deleted)
370                }
371            }
372        }
373    }
374}
375
376impl<T: Clone> Tracked<T> {
377    /// Consumes the tracked wrapper and returns the current entity value.
378    pub fn into_current(self) -> T {
379        self.current().clone()
380    }
381}
382
383impl<T: Entity> Tracked<T> {
384    pub(crate) fn attach_registry_loaded(
385        &mut self,
386        registry: TrackingRegistryHandle,
387        key: SqlValue,
388    ) -> Result<(), OrmError> {
389        let registration_id = registry.register_loaded(self, key)?;
390        self.registration_id = Some(registration_id);
391        self.tracking_registry = Some(registry);
392        Ok(())
393    }
394
395    pub(crate) fn attach_registry_added(&mut self, registry: TrackingRegistryHandle) {
396        let registration_id = registry.register_added(self);
397        self.registration_id = Some(registration_id);
398        self.tracking_registry = Some(registry);
399    }
400
401    #[cfg(test)]
402    pub(crate) fn attach_registry(&mut self, registry: TrackingRegistryHandle) {
403        self.attach_registry_added(registry);
404    }
405}
406
407impl<T> Deref for Tracked<T> {
408    type Target = T;
409
410    fn deref(&self) -> &Self::Target {
411        self.current()
412    }
413}
414
415impl<T> DerefMut for Tracked<T> {
416    fn deref_mut(&mut self) -> &mut Self::Target {
417        self.current_mut()
418    }
419}
420
421impl TrackingRegistry {
422    pub(crate) fn register_loaded<E: Entity>(
423        &self,
424        tracked: &Tracked<E>,
425        key: SqlValue,
426    ) -> Result<usize, OrmError> {
427        let identity =
428            TrackedIdentity::for_entity::<E>(TrackedPrimaryKeyIdentity::Simple(key.clone()));
429        let mut state = self.state.lock().expect("tracking registry mutex poisoned");
430        if state.entries.iter().any(|entry| entry.identity == identity) {
431            return Err(OrmError::new(format!(
432                "entity `{}` with primary key value `{:?}` is already tracked in this context",
433                E::metadata().rust_name,
434                key
435            )));
436        }
437
438        Ok(state.push_registration(tracked, identity))
439    }
440
441    pub(crate) fn register_added<E: Entity>(&self, tracked: &Tracked<E>) -> usize {
442        let mut state = self.state.lock().expect("tracking registry mutex poisoned");
443        let temporary_identity = state.next_temporary_identity;
444        state.next_temporary_identity += 1;
445        let identity = TrackedIdentity::for_entity::<E>(TrackedPrimaryKeyIdentity::Temporary(
446            temporary_identity,
447        ));
448        state.push_registration(tracked, identity)
449    }
450
451    pub(crate) fn unregister(&self, registration_id: usize) {
452        let mut state = self.state.lock().expect("tracking registry mutex poisoned");
453        state
454            .entries
455            .retain(|entry| entry.registration_id != registration_id);
456    }
457
458    pub fn clear(&self) {
459        self.state
460            .lock()
461            .expect("tracking registry mutex poisoned")
462            .entries
463            .clear();
464    }
465
466    pub(crate) fn tracked_for<E: Entity>(&self) -> Vec<RegisteredTracked<E>> {
467        let state = self.state.lock().expect("tracking registry mutex poisoned");
468
469        state
470            .entries
471            .iter()
472            .filter(|entry| entry.entity_type_id == TypeId::of::<E>())
473            .map(|entry| RegisteredTracked::<E> {
474                registration_id: entry.registration_id,
475                inner_address: entry.inner_address,
476                _entity: PhantomData,
477            })
478            .collect()
479    }
480
481    pub(crate) fn update_persisted_identity<E: Entity>(
482        &self,
483        registration_id: usize,
484        key: SqlValue,
485    ) -> Result<(), OrmError> {
486        let identity =
487            TrackedIdentity::for_entity::<E>(TrackedPrimaryKeyIdentity::Simple(key.clone()));
488        let mut state = self.state.lock().expect("tracking registry mutex poisoned");
489
490        if state
491            .entries
492            .iter()
493            .any(|entry| entry.registration_id != registration_id && entry.identity == identity)
494        {
495            return Err(OrmError::new(format!(
496                "entity `{}` with primary key value `{:?}` is already tracked in this context",
497                E::metadata().rust_name,
498                key
499            )));
500        }
501
502        let entry = state
503            .entries
504            .iter_mut()
505            .find(|entry| entry.registration_id == registration_id)
506            .ok_or_else(|| OrmError::new("tracked entity registration was not found"))?;
507        entry.identity = identity;
508        Ok(())
509    }
510
511    pub fn entry_count(&self) -> usize {
512        self.state
513            .lock()
514            .expect("tracking registry mutex poisoned")
515            .entries
516            .len()
517    }
518
519    pub fn registrations(&self) -> Vec<TrackedEntityRegistration> {
520        self.state
521            .lock()
522            .expect("tracking registry mutex poisoned")
523            .entries
524            .iter()
525            .map(|entry| TrackedEntityRegistration {
526                entity_rust_name: entry.entity_rust_name,
527                state: unsafe { (entry.state_reader)(entry.inner_address as *const ()) },
528            })
529            .collect()
530    }
531}
532
533#[doc(hidden)]
534pub fn save_changes_operation_plan(
535    entities: &[&'static EntityMetadata],
536) -> Result<SaveChangesOperationPlan, OrmError> {
537    let insert_order = topological_entity_order(entities)?;
538    let mut delete_order = insert_order.clone();
539    delete_order.reverse();
540
541    Ok(SaveChangesOperationPlan {
542        added_order: insert_order.clone(),
543        modified_order: insert_order,
544        deleted_order: delete_order,
545    })
546}
547
548impl SaveChangesOperationPlan {
549    pub fn added_order(&self) -> &[usize] {
550        &self.added_order
551    }
552
553    pub fn modified_order(&self) -> &[usize] {
554        &self.modified_order
555    }
556
557    pub fn deleted_order(&self) -> &[usize] {
558        &self.deleted_order
559    }
560}
561
562fn topological_entity_order(entities: &[&'static EntityMetadata]) -> Result<Vec<usize>, OrmError> {
563    let mut outgoing_edges = vec![Vec::<usize>::new(); entities.len()];
564    let mut incoming_edge_count = vec![0usize; entities.len()];
565
566    for (child_index, child) in entities.iter().enumerate() {
567        for foreign_key in child.foreign_keys {
568            if foreign_key.columns.len() != 1 || foreign_key.referenced_columns.len() != 1 {
569                continue;
570            }
571
572            let Some(parent_index) = entities.iter().position(|candidate| {
573                candidate.schema == foreign_key.referenced_schema
574                    && candidate.table == foreign_key.referenced_table
575            }) else {
576                continue;
577            };
578
579            if parent_index == child_index || outgoing_edges[parent_index].contains(&child_index) {
580                continue;
581            }
582
583            outgoing_edges[parent_index].push(child_index);
584            incoming_edge_count[child_index] += 1;
585        }
586    }
587
588    let mut order = Vec::with_capacity(entities.len());
589    let mut ready: Vec<usize> = incoming_edge_count
590        .iter()
591        .enumerate()
592        .filter_map(|(index, count)| (*count == 0).then_some(index))
593        .collect();
594
595    while !ready.is_empty() {
596        ready.sort_unstable();
597        let entity_index = ready.remove(0);
598        order.push(entity_index);
599
600        for child_index in &outgoing_edges[entity_index] {
601            incoming_edge_count[*child_index] -= 1;
602            if incoming_edge_count[*child_index] == 0 {
603                ready.push(*child_index);
604            }
605        }
606    }
607
608    if order.len() != entities.len() {
609        return Err(OrmError::new(
610            "save_changes cannot determine a deterministic order for tracked operations because the context contains a foreign-key cycle",
611        ));
612    }
613
614    Ok(order)
615}
616
617impl TrackingRegistryState {
618    fn push_registration<E: Entity>(
619        &mut self,
620        tracked: &Tracked<E>,
621        identity: TrackedIdentity,
622    ) -> usize {
623        let registration_id = self.next_registration_id;
624        self.next_registration_id += 1;
625        self.entries.push(TrackingRegistration {
626            registration_id,
627            identity,
628            entity_type_id: TypeId::of::<E>(),
629            entity_rust_name: E::metadata().rust_name,
630            inner_address: tracked.inner.as_ref() as *const TrackedInner<E> as usize,
631            state_reader: state_reader::<E>,
632        });
633        registration_id
634    }
635}
636
637impl TrackedIdentity {
638    fn for_entity<E: Entity>(primary_key: TrackedPrimaryKeyIdentity) -> Self {
639        let metadata = E::metadata();
640        Self {
641            entity_type_id: TypeId::of::<E>(),
642            entity_rust_name: metadata.rust_name,
643            schema: metadata.schema,
644            table: metadata.table,
645            primary_key,
646        }
647    }
648}
649
650impl<E: Clone> RegisteredTracked<E> {
651    pub(crate) fn registration_id(&self) -> usize {
652        self.registration_id
653    }
654
655    pub(crate) fn state(&self) -> EntityState {
656        unsafe { (&*(self.inner_address as *const TrackedInner<E>)).state }
657    }
658
659    pub(crate) fn current_clone(&self) -> E {
660        unsafe {
661            (&*(self.inner_address as *const TrackedInner<E>))
662                .current
663                .clone()
664        }
665    }
666
667    pub(crate) fn accept_current(&self) {
668        unsafe {
669            let inner = self.inner_address as *mut TrackedInner<E>;
670            (*inner).original = (*inner).current.clone();
671            (*inner).state = EntityState::Unchanged;
672        }
673    }
674
675    pub(crate) fn sync_persisted(&self, persisted: E) {
676        unsafe {
677            let inner = self.inner_address as *mut TrackedInner<E>;
678            (*inner).original = persisted.clone();
679            (*inner).current = persisted;
680            (*inner).state = EntityState::Unchanged;
681        }
682    }
683}
684
685impl<E: EntityPersist> RegisteredTracked<E> {
686    pub(crate) fn has_persisted_changes(&self) -> bool {
687        unsafe {
688            let inner = &*(self.inner_address as *const TrackedInner<E>);
689            E::has_persisted_changes(&inner.original, &inner.current)
690        }
691    }
692}
693
694unsafe fn state_reader<E>(ptr: *const ()) -> EntityState {
695    unsafe { (&*(ptr.cast::<TrackedInner<E>>())).state }
696}
697
698impl<T: Clone> Clone for Tracked<T> {
699    fn clone(&self) -> Self {
700        Self {
701            inner: Box::new(TrackedInner {
702                original: self.original().clone(),
703                current: self.current().clone(),
704                state: self.state(),
705            }),
706            registration_id: None,
707            tracking_registry: None,
708        }
709    }
710}
711
712impl<T: core::fmt::Debug> core::fmt::Debug for Tracked<T> {
713    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
714        f.debug_struct("Tracked")
715            .field("original", self.original())
716            .field("current", self.current())
717            .field("state", &self.state())
718            .finish()
719    }
720}
721
722impl<T: PartialEq> PartialEq for Tracked<T> {
723    fn eq(&self, other: &Self) -> bool {
724        self.original() == other.original()
725            && self.current() == other.current()
726            && self.state() == other.state()
727    }
728}
729
730impl<T: Eq> Eq for Tracked<T> {}
731
732impl<T> Drop for Tracked<T> {
733    fn drop(&mut self) {
734        if let (Some(registration_id), Some(registry)) =
735            (self.registration_id.take(), self.tracking_registry.take())
736        {
737            registry.unregister(registration_id);
738        }
739    }
740}
741
742#[cfg(test)]
743mod tests {
744    use super::{
745        EntityState, Tracked, TrackedEntityRegistration, TrackingRegistry,
746        save_changes_operation_plan,
747    };
748    use sql_orm_core::{
749        Entity, EntityMetadata, ForeignKeyMetadata, PrimaryKeyMetadata, ReferentialAction, SqlValue,
750    };
751    use std::sync::Arc;
752
753    #[derive(Clone)]
754    struct DummyEntity;
755
756    #[derive(Clone)]
757    struct DummyEntityAlias;
758
759    static DUMMY_ENTITY_METADATA: EntityMetadata = EntityMetadata {
760        rust_name: "DummyEntity",
761        schema: "dbo",
762        table: "dummy_entities",
763        renamed_from: None,
764        columns: &[],
765        primary_key: PrimaryKeyMetadata {
766            name: None,
767            columns: &[],
768        },
769        indexes: &[],
770        foreign_keys: &[],
771        navigations: &[],
772    };
773
774    static ORDER_METADATA: EntityMetadata = EntityMetadata {
775        rust_name: "Order",
776        schema: "sales",
777        table: "orders",
778        renamed_from: None,
779        columns: &[],
780        primary_key: PrimaryKeyMetadata {
781            name: None,
782            columns: &["id"],
783        },
784        indexes: &[],
785        foreign_keys: &[],
786        navigations: &[],
787    };
788
789    static ORDER_ITEM_FOREIGN_KEYS: [ForeignKeyMetadata; 1] = [ForeignKeyMetadata::new(
790        "fk_order_items_orders",
791        &["order_id"],
792        "sales",
793        "orders",
794        &["id"],
795        ReferentialAction::NoAction,
796        ReferentialAction::NoAction,
797    )];
798
799    static ORDER_ITEM_METADATA: EntityMetadata = EntityMetadata {
800        rust_name: "OrderItem",
801        schema: "sales",
802        table: "order_items",
803        renamed_from: None,
804        columns: &[],
805        primary_key: PrimaryKeyMetadata {
806            name: None,
807            columns: &["id"],
808        },
809        indexes: &[],
810        foreign_keys: &ORDER_ITEM_FOREIGN_KEYS,
811        navigations: &[],
812    };
813
814    static CATEGORY_FOREIGN_KEYS: [ForeignKeyMetadata; 1] = [ForeignKeyMetadata::new(
815        "fk_categories_parent",
816        &["parent_id"],
817        "catalog",
818        "categories",
819        &["id"],
820        ReferentialAction::NoAction,
821        ReferentialAction::NoAction,
822    )];
823
824    static CATEGORY_METADATA: EntityMetadata = EntityMetadata {
825        rust_name: "Category",
826        schema: "catalog",
827        table: "categories",
828        renamed_from: None,
829        columns: &[],
830        primary_key: PrimaryKeyMetadata {
831            name: None,
832            columns: &["id"],
833        },
834        indexes: &[],
835        foreign_keys: &CATEGORY_FOREIGN_KEYS,
836        navigations: &[],
837    };
838
839    static CYCLE_A_FOREIGN_KEYS: [ForeignKeyMetadata; 1] = [ForeignKeyMetadata::new(
840        "fk_cycle_a_cycle_b",
841        &["cycle_b_id"],
842        "dbo",
843        "cycle_b",
844        &["id"],
845        ReferentialAction::NoAction,
846        ReferentialAction::NoAction,
847    )];
848
849    static CYCLE_B_FOREIGN_KEYS: [ForeignKeyMetadata; 1] = [ForeignKeyMetadata::new(
850        "fk_cycle_b_cycle_a",
851        &["cycle_a_id"],
852        "dbo",
853        "cycle_a",
854        &["id"],
855        ReferentialAction::NoAction,
856        ReferentialAction::NoAction,
857    )];
858
859    static CYCLE_A_METADATA: EntityMetadata = EntityMetadata {
860        rust_name: "CycleA",
861        schema: "dbo",
862        table: "cycle_a",
863        renamed_from: None,
864        columns: &[],
865        primary_key: PrimaryKeyMetadata {
866            name: None,
867            columns: &["id"],
868        },
869        indexes: &[],
870        foreign_keys: &CYCLE_A_FOREIGN_KEYS,
871        navigations: &[],
872    };
873
874    static CYCLE_B_METADATA: EntityMetadata = EntityMetadata {
875        rust_name: "CycleB",
876        schema: "dbo",
877        table: "cycle_b",
878        renamed_from: None,
879        columns: &[],
880        primary_key: PrimaryKeyMetadata {
881            name: None,
882            columns: &["id"],
883        },
884        indexes: &[],
885        foreign_keys: &CYCLE_B_FOREIGN_KEYS,
886        navigations: &[],
887    };
888
889    impl Entity for DummyEntity {
890        fn metadata() -> &'static EntityMetadata {
891            &DUMMY_ENTITY_METADATA
892        }
893    }
894
895    impl Entity for DummyEntityAlias {
896        fn metadata() -> &'static EntityMetadata {
897            &DUMMY_ENTITY_METADATA
898        }
899    }
900
901    #[test]
902    fn tracked_loaded_value_keeps_original_and_current_snapshots() {
903        let tracked = Tracked::from_loaded(String::from("Ana"));
904
905        assert_eq!(tracked.state(), EntityState::Unchanged);
906        assert_eq!(tracked.original(), "Ana");
907        assert_eq!(tracked.current(), "Ana");
908    }
909
910    #[test]
911    fn tracked_added_value_starts_in_added_state() {
912        let tracked = Tracked::from_added(String::from("Luis"));
913
914        assert_eq!(tracked.state(), EntityState::Added);
915        assert_eq!(tracked.original(), "Luis");
916        assert_eq!(tracked.current(), "Luis");
917    }
918
919    #[test]
920    fn tracked_can_release_current_value() {
921        let tracked = Tracked::from_loaded(String::from("Maria"));
922
923        assert_eq!(tracked.into_current(), "Maria");
924    }
925
926    #[test]
927    fn into_current_consumes_registered_wrapper_and_unregisters_it() {
928        let registry = Arc::new(TrackingRegistry::default());
929        let mut tracked = Tracked::from_loaded(DummyEntity);
930        tracked.attach_registry(Arc::clone(&registry));
931
932        assert_eq!(registry.entry_count(), 1);
933
934        let _current = tracked.into_current();
935
936        assert_eq!(registry.entry_count(), 0);
937    }
938
939    #[test]
940    fn cloned_tracked_wrapper_is_detached_from_original_registry_entry() {
941        let registry = Arc::new(TrackingRegistry::default());
942        let mut original = Tracked::from_loaded(DummyEntity);
943        original.attach_registry(Arc::clone(&registry));
944        original.mark_modified();
945
946        let clone = original.clone();
947
948        assert_eq!(registry.entry_count(), 1);
949        assert_eq!(clone.state(), EntityState::Modified);
950
951        drop(clone);
952
953        assert_eq!(registry.entry_count(), 1);
954        assert_eq!(registry.registrations()[0].state, EntityState::Modified);
955    }
956
957    #[test]
958    fn mutable_access_transitions_loaded_entity_to_modified() {
959        let mut tracked = Tracked::from_loaded(String::from("Ana"));
960
961        tracked.push_str(" Maria");
962
963        assert_eq!(tracked.state(), EntityState::Modified);
964        assert_eq!(tracked.original(), "Ana");
965        assert_eq!(tracked.current(), "Ana Maria");
966    }
967
968    #[test]
969    fn current_mut_transitions_loaded_entity_to_modified() {
970        let mut tracked = Tracked::from_loaded(String::from("Luis"));
971
972        tracked.current_mut().push_str(" Alberto");
973
974        assert_eq!(tracked.state(), EntityState::Modified);
975        assert_eq!(tracked.original(), "Luis");
976        assert_eq!(tracked.current(), "Luis Alberto");
977    }
978
979    #[test]
980    fn explicit_mark_modified_transitions_unchanged_only() {
981        let mut loaded = Tracked::from_loaded(String::from("Ana"));
982        loaded.mark_modified();
983
984        let mut added = Tracked::from_added(String::from("Luis"));
985        added.mark_modified();
986
987        let mut deleted = Tracked::from_loaded(String::from("Maria"));
988        deleted.mark_deleted();
989        deleted.mark_modified();
990
991        assert_eq!(loaded.state(), EntityState::Modified);
992        assert_eq!(added.state(), EntityState::Added);
993        assert_eq!(deleted.state(), EntityState::Deleted);
994    }
995
996    #[test]
997    fn explicit_mark_deleted_transitions_wrapper_to_deleted() {
998        let mut tracked = Tracked::from_loaded(String::from("Ana"));
999
1000        tracked.mark_deleted();
1001
1002        assert_eq!(tracked.state(), EntityState::Deleted);
1003    }
1004
1005    #[test]
1006    fn explicit_mark_unchanged_accepts_current_snapshot() {
1007        let mut tracked = Tracked::from_loaded(String::from("Ana"));
1008        tracked.current_mut().push_str(" Maria");
1009
1010        tracked.mark_unchanged();
1011
1012        assert_eq!(tracked.state(), EntityState::Unchanged);
1013        assert_eq!(tracked.original(), "Ana Maria");
1014        assert_eq!(tracked.current(), "Ana Maria");
1015    }
1016
1017    #[test]
1018    fn explicit_mark_unchanged_restores_deleted_wrapper_with_current_snapshot() {
1019        let mut tracked = Tracked::from_loaded(String::from("Ana"));
1020        tracked.current_mut().push_str(" Maria");
1021        tracked.mark_deleted();
1022
1023        tracked.mark_unchanged();
1024
1025        assert_eq!(tracked.state(), EntityState::Unchanged);
1026        assert_eq!(tracked.original(), "Ana Maria");
1027        assert_eq!(tracked.current(), "Ana Maria");
1028    }
1029
1030    #[test]
1031    fn explicit_mark_unchanged_on_registered_wrapper_updates_registry_state() {
1032        let registry = Arc::new(TrackingRegistry::default());
1033        let mut tracked = Tracked::from_loaded(DummyEntity);
1034        tracked.attach_registry(Arc::clone(&registry));
1035        tracked.mark_deleted();
1036
1037        tracked.mark_unchanged();
1038
1039        assert_eq!(tracked.state(), EntityState::Unchanged);
1040        assert_eq!(registry.entry_count(), 1);
1041        assert_eq!(registry.registrations()[0].state, EntityState::Unchanged);
1042    }
1043
1044    #[test]
1045    fn mark_deleted_transitions_any_registered_entity_to_deleted() {
1046        let registry = Arc::new(TrackingRegistry::default());
1047        let mut tracked = Tracked::from_loaded(DummyEntity);
1048        tracked.attach_registry(Arc::clone(&registry));
1049
1050        tracked.mark_deleted();
1051
1052        assert_eq!(tracked.state(), EntityState::Deleted);
1053        assert_eq!(registry.registrations()[0].state, EntityState::Deleted);
1054    }
1055
1056    #[test]
1057    fn mark_deleted_on_added_registered_entry_cancels_pending_insert() {
1058        let registry = Arc::new(TrackingRegistry::default());
1059        let mut tracked = Tracked::from_added(DummyEntity);
1060        tracked.attach_registry_added(Arc::clone(&registry));
1061
1062        tracked.mark_deleted();
1063
1064        assert_eq!(tracked.state(), EntityState::Deleted);
1065        assert_eq!(registry.entry_count(), 0);
1066    }
1067
1068    #[test]
1069    fn mutable_access_keeps_added_state_for_new_entities() {
1070        let mut tracked = Tracked::from_added(String::from("Maria"));
1071
1072        tracked.push_str(" Fernanda");
1073
1074        assert_eq!(tracked.state(), EntityState::Added);
1075        assert_eq!(tracked.original(), "Maria");
1076        assert_eq!(tracked.current(), "Maria Fernanda");
1077    }
1078
1079    #[test]
1080    fn tracking_registry_records_loaded_entities() {
1081        let registry = Arc::new(TrackingRegistry::default());
1082        let mut tracked = Tracked::from_loaded(DummyEntity);
1083
1084        tracked
1085            .attach_registry_loaded(Arc::clone(&registry), SqlValue::I64(7))
1086            .unwrap();
1087
1088        assert_eq!(registry.entry_count(), 1);
1089        assert_eq!(
1090            registry.registrations(),
1091            vec![TrackedEntityRegistration {
1092                entity_rust_name: "DummyEntity",
1093                state: EntityState::Unchanged,
1094            }]
1095        );
1096    }
1097
1098    #[test]
1099    fn tracking_registry_records_added_entities() {
1100        let registry = Arc::new(TrackingRegistry::default());
1101        let mut tracked = Tracked::from_added(DummyEntity);
1102
1103        tracked.attach_registry(Arc::clone(&registry));
1104
1105        assert_eq!(registry.entry_count(), 1);
1106        assert_eq!(
1107            registry.registrations(),
1108            vec![TrackedEntityRegistration {
1109                entity_rust_name: "DummyEntity",
1110                state: EntityState::Added,
1111            }]
1112        );
1113    }
1114
1115    #[test]
1116    fn tracking_registry_rejects_duplicate_loaded_identity() {
1117        let registry = Arc::new(TrackingRegistry::default());
1118        let mut first = Tracked::from_loaded(DummyEntity);
1119        let mut second = Tracked::from_loaded(DummyEntity);
1120
1121        first
1122            .attach_registry_loaded(Arc::clone(&registry), SqlValue::I64(7))
1123            .unwrap();
1124        let error = second
1125            .attach_registry_loaded(Arc::clone(&registry), SqlValue::I64(7))
1126            .unwrap_err();
1127
1128        assert_eq!(registry.entry_count(), 1);
1129        assert!(error.message().contains("already tracked"));
1130    }
1131
1132    #[test]
1133    fn duplicate_loaded_identity_error_leaves_rejected_wrapper_detached() {
1134        let registry = Arc::new(TrackingRegistry::default());
1135        let mut first = Tracked::from_loaded(DummyEntity);
1136
1137        first
1138            .attach_registry_loaded(Arc::clone(&registry), SqlValue::I64(7))
1139            .unwrap();
1140
1141        {
1142            let mut duplicate = Tracked::from_loaded(DummyEntity);
1143            let error = duplicate
1144                .attach_registry_loaded(Arc::clone(&registry), SqlValue::I64(7))
1145                .unwrap_err();
1146
1147            assert!(error.message().contains("already tracked"));
1148            assert_eq!(duplicate.state(), EntityState::Unchanged);
1149            assert_eq!(registry.entry_count(), 1);
1150        }
1151
1152        assert_eq!(registry.entry_count(), 1);
1153        assert_eq!(registry.registrations()[0].state, EntityState::Unchanged);
1154    }
1155
1156    #[test]
1157    fn tracking_registry_scopes_loaded_identity_by_rust_type() {
1158        let registry = Arc::new(TrackingRegistry::default());
1159        let mut first = Tracked::from_loaded(DummyEntity);
1160        let mut second = Tracked::from_loaded(DummyEntityAlias);
1161
1162        first
1163            .attach_registry_loaded(Arc::clone(&registry), SqlValue::I64(7))
1164            .unwrap();
1165        second
1166            .attach_registry_loaded(Arc::clone(&registry), SqlValue::I64(7))
1167            .unwrap();
1168
1169        assert_eq!(registry.entry_count(), 2);
1170    }
1171
1172    #[test]
1173    fn tracking_registry_allows_multiple_added_entities_with_temporary_identities() {
1174        let registry = Arc::new(TrackingRegistry::default());
1175        let mut first = Tracked::from_added(DummyEntity);
1176        let mut second = Tracked::from_added(DummyEntity);
1177
1178        first.attach_registry_added(Arc::clone(&registry));
1179        second.attach_registry_added(Arc::clone(&registry));
1180
1181        assert_eq!(registry.entry_count(), 2);
1182    }
1183
1184    #[test]
1185    fn tracking_registry_updates_temporary_identity_to_persisted_identity() {
1186        let registry = Arc::new(TrackingRegistry::default());
1187        let mut tracked = Tracked::from_added(DummyEntity);
1188        tracked.attach_registry_added(Arc::clone(&registry));
1189
1190        registry
1191            .update_persisted_identity::<DummyEntity>(
1192                tracked.registration_id.expect("registered"),
1193                SqlValue::I64(11),
1194            )
1195            .unwrap();
1196
1197        let mut duplicate = Tracked::from_loaded(DummyEntity);
1198        let error = duplicate
1199            .attach_registry_loaded(Arc::clone(&registry), SqlValue::I64(11))
1200            .unwrap_err();
1201
1202        assert!(error.message().contains("already tracked"));
1203    }
1204
1205    #[test]
1206    fn tracking_registry_rejects_persisted_identity_update_collision_without_mutating_entry() {
1207        let registry = Arc::new(TrackingRegistry::default());
1208        let mut existing = Tracked::from_loaded(DummyEntity);
1209        let mut pending = Tracked::from_added(DummyEntity);
1210
1211        existing
1212            .attach_registry_loaded(Arc::clone(&registry), SqlValue::I64(11))
1213            .unwrap();
1214        pending.attach_registry_added(Arc::clone(&registry));
1215
1216        let pending_registration = pending.registration_id.expect("registered pending entity");
1217        let error = registry
1218            .update_persisted_identity::<DummyEntity>(pending_registration, SqlValue::I64(11))
1219            .unwrap_err();
1220
1221        assert!(error.message().contains("already tracked"));
1222        assert_eq!(registry.entry_count(), 2);
1223
1224        let mut duplicate = Tracked::from_loaded(DummyEntity);
1225        let duplicate_error = duplicate
1226            .attach_registry_loaded(Arc::clone(&registry), SqlValue::I64(11))
1227            .unwrap_err();
1228        assert!(duplicate_error.message().contains("already tracked"));
1229
1230        registry
1231            .update_persisted_identity::<DummyEntity>(pending_registration, SqlValue::I64(12))
1232            .unwrap();
1233
1234        let mut second_duplicate = Tracked::from_loaded(DummyEntity);
1235        let second_duplicate_error = second_duplicate
1236            .attach_registry_loaded(Arc::clone(&registry), SqlValue::I64(12))
1237            .unwrap_err();
1238        assert!(second_duplicate_error.message().contains("already tracked"));
1239    }
1240
1241    #[test]
1242    fn tracking_registry_rejects_persisted_identity_update_for_missing_registration() {
1243        let registry = TrackingRegistry::default();
1244
1245        let error = registry
1246            .update_persisted_identity::<DummyEntity>(99, SqlValue::I64(11))
1247            .unwrap_err();
1248
1249        assert_eq!(error.message(), "tracked entity registration was not found");
1250    }
1251
1252    #[test]
1253    fn tracking_registry_clear_removes_all_entries() {
1254        let registry = Arc::new(TrackingRegistry::default());
1255        let mut first = Tracked::from_added(DummyEntity);
1256        let mut second = Tracked::from_added(DummyEntity);
1257        first.attach_registry_added(Arc::clone(&registry));
1258        second.attach_registry_added(Arc::clone(&registry));
1259
1260        registry.clear();
1261
1262        assert_eq!(registry.entry_count(), 0);
1263        assert!(registry.registrations().is_empty());
1264    }
1265
1266    #[test]
1267    fn detach_registry_unregisters_without_dropping_wrapper() {
1268        let registry = Arc::new(TrackingRegistry::default());
1269        let mut tracked = Tracked::from_loaded(DummyEntity);
1270        tracked.attach_registry(Arc::clone(&registry));
1271
1272        tracked.detach_registry();
1273
1274        assert_eq!(registry.entry_count(), 0);
1275        assert_eq!(tracked.state(), EntityState::Unchanged);
1276    }
1277
1278    #[test]
1279    fn public_detach_is_idempotent_and_keeps_visible_state() {
1280        let registry = Arc::new(TrackingRegistry::default());
1281        let mut tracked = Tracked::from_loaded(DummyEntity);
1282        tracked.attach_registry(Arc::clone(&registry));
1283        tracked.mark_deleted();
1284
1285        tracked.detach();
1286        tracked.detach();
1287
1288        assert_eq!(registry.entry_count(), 0);
1289        assert_eq!(tracked.state(), EntityState::Deleted);
1290    }
1291
1292    #[test]
1293    fn public_detach_unregisters_without_resetting_state() {
1294        let registry = Arc::new(TrackingRegistry::default());
1295        let mut tracked = Tracked::from_loaded(DummyEntity);
1296        tracked.attach_registry(Arc::clone(&registry));
1297        tracked.mark_modified();
1298
1299        tracked.detach();
1300
1301        assert_eq!(registry.entry_count(), 0);
1302        assert_eq!(tracked.state(), EntityState::Modified);
1303    }
1304
1305    #[test]
1306    fn tracking_registry_unregister_missing_registration_is_noop() {
1307        let registry = Arc::new(TrackingRegistry::default());
1308        let mut tracked = Tracked::from_loaded(DummyEntity);
1309        tracked.attach_registry(Arc::clone(&registry));
1310
1311        registry.unregister(99);
1312
1313        assert_eq!(registry.entry_count(), 1);
1314        assert_eq!(registry.registrations()[0].state, EntityState::Unchanged);
1315    }
1316
1317    #[test]
1318    fn dropping_tracked_entity_unregisters_it_from_registry() {
1319        let registry = Arc::new(TrackingRegistry::default());
1320
1321        {
1322            let mut tracked = Tracked::from_loaded(DummyEntity);
1323            tracked.attach_registry(Arc::clone(&registry));
1324            assert_eq!(registry.entry_count(), 1);
1325        }
1326
1327        assert_eq!(registry.entry_count(), 0);
1328    }
1329
1330    #[test]
1331    fn save_changes_plan_orders_added_parents_before_children() {
1332        let plan = save_changes_operation_plan(&[
1333            &ORDER_ITEM_METADATA,
1334            &DUMMY_ENTITY_METADATA,
1335            &ORDER_METADATA,
1336        ])
1337        .unwrap();
1338
1339        assert_eq!(plan.added_order(), &[1, 2, 0]);
1340        assert_eq!(plan.modified_order(), &[1, 2, 0]);
1341    }
1342
1343    #[test]
1344    fn save_changes_plan_orders_deleted_children_before_parents() {
1345        let plan = save_changes_operation_plan(&[
1346            &ORDER_ITEM_METADATA,
1347            &DUMMY_ENTITY_METADATA,
1348            &ORDER_METADATA,
1349        ])
1350        .unwrap();
1351
1352        assert_eq!(plan.deleted_order(), &[0, 2, 1]);
1353    }
1354
1355    #[test]
1356    fn save_changes_plan_preserves_context_order_without_dependencies() {
1357        let plan = save_changes_operation_plan(&[&ORDER_METADATA, &DUMMY_ENTITY_METADATA]).unwrap();
1358
1359        assert_eq!(plan.added_order(), &[0, 1]);
1360        assert_eq!(plan.modified_order(), &[0, 1]);
1361        assert_eq!(plan.deleted_order(), &[1, 0]);
1362    }
1363
1364    #[test]
1365    fn save_changes_plan_ignores_foreign_keys_to_entities_outside_context() {
1366        let plan =
1367            save_changes_operation_plan(&[&ORDER_ITEM_METADATA, &DUMMY_ENTITY_METADATA]).unwrap();
1368
1369        assert_eq!(plan.added_order(), &[0, 1]);
1370        assert_eq!(plan.modified_order(), &[0, 1]);
1371        assert_eq!(plan.deleted_order(), &[1, 0]);
1372    }
1373
1374    #[test]
1375    fn save_changes_plan_ignores_simple_self_references() {
1376        let plan =
1377            save_changes_operation_plan(&[&CATEGORY_METADATA, &DUMMY_ENTITY_METADATA]).unwrap();
1378
1379        assert_eq!(plan.added_order(), &[0, 1]);
1380        assert_eq!(plan.modified_order(), &[0, 1]);
1381        assert_eq!(plan.deleted_order(), &[1, 0]);
1382    }
1383
1384    #[test]
1385    fn save_changes_plan_rejects_foreign_key_cycles() {
1386        let error =
1387            save_changes_operation_plan(&[&CYCLE_A_METADATA, &CYCLE_B_METADATA]).unwrap_err();
1388
1389        assert!(error.message().contains("foreign-key cycle"));
1390    }
1391}