Skip to main content

teaql_runtime/
entity_runtime.rs

1use std::any::{Any, TypeId};
2use std::borrow::Cow;
3use std::collections::{BTreeMap, BTreeSet, HashMap};
4use std::sync::{Arc, Mutex, OnceLock, Weak};
5
6use teaql_core::{EntitySnapshot, MutationValues, SmartList, Value};
7
8/// The explicit load state of a relation stored in the runtime identity graph.
9///
10/// Reading this state never performs I/O. `NotLoaded` means exactly that the
11/// current query did not install a value for the relation; callers must issue
12/// an explicit query if they need it.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum LoadedRelation {
15    Loaded,
16    Empty,
17    NotLoaded,
18}
19
20/// A borrowed view of a relation in the runtime identity graph.
21///
22/// `value()` is present for both `Loaded` and loaded-empty collection values.
23/// It is absent for a null to-one relation and for `NotLoaded`.
24#[derive(Debug, Clone, Copy)]
25pub struct RelationHandle<'a, T> {
26    state: LoadedRelation,
27    value: Option<&'a T>,
28}
29
30impl<'a, T> RelationHandle<'a, T> {
31    fn new(state: LoadedRelation, value: Option<&'a T>) -> Self {
32        Self { state, value }
33    }
34
35    pub fn state(&self) -> LoadedRelation {
36        self.state
37    }
38
39    pub fn value(&self) -> Option<&'a T> {
40        self.value
41    }
42
43    pub fn is_loaded(&self) -> bool {
44        self.state != LoadedRelation::NotLoaded
45    }
46
47    pub fn is_empty(&self) -> bool {
48        self.state == LoadedRelation::Empty
49    }
50}
51
52#[derive(Debug, Clone)]
53pub struct EntityKey {
54    pub entity: Cow<'static, str>,
55    pub id: Value,
56    id_key: EntityIdentityKey,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
60enum EntityIdentityKey {
61    Null,
62    Bool(bool),
63    I64(i64),
64    U64(u64),
65    F64(u64),
66    Decimal(rust_decimal::Decimal),
67    Text(String),
68    Date(chrono::NaiveDate),
69    Timestamp(i64),
70    Other(String),
71}
72
73impl EntityKey {
74    pub fn new(entity: impl Into<String>, id: impl Into<Value>) -> Self {
75        let id = id.into();
76        Self {
77            entity: Cow::Owned(entity.into()),
78            id_key: entity_identity_key(&id),
79            id,
80        }
81    }
82
83    pub fn new_static(entity: &'static str, id: impl Into<Value>) -> Self {
84        let id = id.into();
85        Self {
86            entity: Cow::Borrowed(entity),
87            id_key: entity_identity_key(&id),
88            id,
89        }
90    }
91}
92
93impl PartialEq for EntityKey {
94    fn eq(&self, other: &Self) -> bool {
95        self.entity == other.entity && self.id_key == other.id_key
96    }
97}
98
99impl Eq for EntityKey {}
100
101impl PartialOrd for EntityKey {
102    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
103        Some(self.cmp(other))
104    }
105}
106
107impl Ord for EntityKey {
108    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
109        self.entity
110            .cmp(&other.entity)
111            .then_with(|| self.id_key.cmp(&other.id_key))
112    }
113}
114
115fn entity_identity_key(value: &Value) -> EntityIdentityKey {
116    match value {
117        Value::Null | Value::TypedNull(_) => EntityIdentityKey::Null,
118        Value::Bool(value) => EntityIdentityKey::Bool(*value),
119        Value::I64(value) => EntityIdentityKey::I64(*value),
120        Value::U64(value) => EntityIdentityKey::U64(*value),
121        Value::F64(value) => EntityIdentityKey::F64(value.to_bits()),
122        Value::Decimal(value) => EntityIdentityKey::Decimal(*value),
123        Value::Text(value) => EntityIdentityKey::Text(value.clone()),
124        Value::Json(value) => EntityIdentityKey::Other(format!("json:{value}")),
125        Value::Date(value) => EntityIdentityKey::Date(*value),
126        Value::Timestamp(value) => EntityIdentityKey::Timestamp(value.0),
127        Value::Object(_) => EntityIdentityKey::Other("object".to_owned()),
128        Value::List(_) => EntityIdentityKey::Other("list".to_owned()),
129    }
130}
131
132#[derive(Default)]
133pub struct EntityGraphBuilder {
134    tables: HashMap<TypeId, EntityTable>,
135    relation_lists: HashMap<RelationListKey, Box<dyn Any + Send + Sync>>,
136}
137
138type EntityTable = HashMap<u64, Box<dyn Any + Send + Sync>>;
139
140#[derive(Debug, Clone, PartialEq, Eq, Hash)]
141struct RelationListKey {
142    owner_entity: String,
143    owner_id: u64,
144    relation: String,
145}
146
147impl EntityGraphBuilder {
148    pub fn install<T>(&mut self, id: u64, entity: T)
149    where
150        T: Any + Send + Sync,
151    {
152        self.tables
153            .entry(TypeId::of::<T>())
154            .or_default()
155            .insert(id, Box::new(entity));
156    }
157
158    pub fn entity_count(&self) -> usize {
159        self.tables.values().map(HashMap::len).sum()
160    }
161
162    pub fn install_relation_list<T>(
163        &mut self,
164        owner_entity: impl Into<String>,
165        owner_id: u64,
166        relation: impl Into<String>,
167        list: SmartList<T>,
168    ) where
169        T: Any + Send + Sync,
170    {
171        self.relation_lists.insert(
172            RelationListKey {
173                owner_entity: crate::canonical_id_space_entity(&owner_entity.into()),
174                owner_id,
175                relation: relation.into(),
176            },
177            Box::new(list),
178        );
179    }
180
181    pub fn install_relation_option<T>(
182        &mut self,
183        owner_entity: impl Into<String>,
184        owner_id: u64,
185        relation: impl Into<String>,
186        value: Option<T>,
187    ) where
188        T: Any + Send + Sync,
189    {
190        self.relation_lists.insert(
191            RelationListKey {
192                owner_entity: crate::canonical_id_space_entity(&owner_entity.into()),
193                owner_id,
194                relation: relation.into(),
195            },
196            Box::new(value),
197        );
198    }
199
200    pub fn relation_list_count(&self) -> usize {
201        self.relation_lists.len()
202    }
203
204    fn freeze(self) -> FrozenEntityGraph {
205        FrozenEntityGraph {
206            tables: self.tables,
207            relation_lists: self.relation_lists,
208        }
209    }
210}
211
212impl std::fmt::Debug for EntityGraphBuilder {
213    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
214        formatter
215            .debug_struct("EntityGraphBuilder")
216            .field("entity_types", &self.tables.len())
217            .field("entities", &self.entity_count())
218            .field("relation_lists", &self.relation_list_count())
219            .finish()
220    }
221}
222
223struct FrozenEntityGraph {
224    tables: HashMap<TypeId, EntityTable>,
225    relation_lists: HashMap<RelationListKey, Box<dyn Any + Send + Sync>>,
226}
227
228impl std::fmt::Debug for FrozenEntityGraph {
229    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
230        formatter
231            .debug_struct("FrozenEntityGraph")
232            .field("entity_types", &self.tables.len())
233            .field(
234                "entities",
235                &self.tables.values().map(HashMap::len).sum::<usize>(),
236            )
237            .field("relation_lists", &self.relation_lists.len())
238            .finish()
239    }
240}
241
242#[derive(Debug, Clone, Default, PartialEq)]
243pub struct EntityChangeSet {
244    changes: BTreeMap<EntityKey, MutationValues>,
245}
246
247#[derive(Debug, Default)]
248struct OriginalVersions {
249    first: Option<(EntityKey, i64)>,
250    overflow: BTreeMap<EntityKey, i64>,
251}
252
253impl OriginalVersions {
254    fn clear(&mut self) {
255        self.first = None;
256        self.overflow.clear();
257    }
258
259    fn get(&self, key: &EntityKey) -> Option<i64> {
260        self.first
261            .as_ref()
262            .and_then(|(first_key, version)| (first_key == key).then_some(*version))
263            .or_else(|| self.overflow.get(key).copied())
264    }
265
266    fn insert(&mut self, key: EntityKey, version: i64) {
267        match &mut self.first {
268            None => self.first = Some((key, version)),
269            Some((first_key, first_version)) if first_key == &key => *first_version = version,
270            Some(_) => {
271                self.overflow.insert(key, version);
272            }
273        }
274    }
275}
276
277impl EntityChangeSet {
278    pub fn is_empty(&self) -> bool {
279        self.changes.is_empty()
280    }
281
282    pub fn set(&mut self, key: EntityKey, field: impl Into<String>, value: Value) {
283        self.changes
284            .entry(key)
285            .or_default()
286            .insert(field.into(), value);
287    }
288
289    pub fn get(&self, key: &EntityKey, field: &str) -> Option<&Value> {
290        self.changes.get(key).and_then(|changes| changes.get(field))
291    }
292
293    pub fn changes(&self) -> &BTreeMap<EntityKey, MutationValues> {
294        &self.changes
295    }
296
297    /// Remove all pending changes for a specific entity key.
298    pub fn clear_entity(&mut self, key: &EntityKey) {
299        self.changes.remove(key);
300    }
301
302    /// Get the set of field names that have been modified for a given entity key.
303    pub fn field_names(&self, key: &EntityKey) -> BTreeSet<String> {
304        self.changes
305            .get(key)
306            .map(|record| record.keys().cloned().collect())
307            .unwrap_or_default()
308    }
309}
310
311#[derive(Debug, Clone, Default, PartialEq)]
312pub struct ChangeSetStack {
313    stack: Vec<EntityChangeSet>,
314}
315
316impl ChangeSetStack {
317    pub fn current_mut(&mut self) -> &mut EntityChangeSet {
318        if self.stack.is_empty() {
319            self.stack.push(EntityChangeSet::default());
320        }
321        self.stack.last_mut().expect("change set stack has current")
322    }
323
324    pub fn current(&self) -> Option<&EntityChangeSet> {
325        self.stack.last()
326    }
327
328    pub fn push(&mut self) {
329        self.stack.push(EntityChangeSet::default());
330    }
331
332    pub fn pop(&mut self) -> Option<EntityChangeSet> {
333        self.stack.pop()
334    }
335
336    pub fn get(&self, key: &EntityKey, field: &str) -> Option<Value> {
337        self.stack
338            .iter()
339            .rev()
340            .find_map(|change_set| change_set.get(key, field).cloned())
341    }
342
343    pub fn set(&mut self, key: EntityKey, field: impl Into<String>, value: Value) {
344        self.current_mut().set(key, field, value);
345    }
346
347    pub fn clear_current(&mut self) {
348        if let Some(current) = self.stack.last_mut() {
349            *current = EntityChangeSet::default();
350        }
351    }
352
353    /// Remove all pending changes for a specific entity key across all stack levels.
354    pub fn clear_entity(&mut self, key: &EntityKey) {
355        for change_set in &mut self.stack {
356            change_set.clear_entity(key);
357        }
358    }
359
360    /// Get the union of all changed field names for a given entity key across all stack levels.
361    /// This is the Rust equivalent of Java's `entity.getUpdatedProperties()`.
362    pub fn changed_field_names(&self, key: &EntityKey) -> BTreeSet<String> {
363        let mut fields = BTreeSet::new();
364        for change_set in &self.stack {
365            fields.extend(change_set.field_names(key));
366        }
367        fields
368    }
369}
370
371#[derive(Debug, Default)]
372struct EntityMutationLedger {
373    change_sets: ChangeSetStack,
374    /// Annotation comment for observability during graph save.
375    comment: Option<String>,
376    /// Entity keys that have been marked for deletion.
377    /// When the entity is saved, the graph save pipeline will treat these as Remove operations.
378    deleted_keys: std::collections::BTreeSet<EntityKey>,
379    /// Entity keys that have been marked as newly inserted.
380    new_keys: std::collections::BTreeSet<EntityKey>,
381    /// The original loaded snapshot, used to avoid redundant fetching during save.
382    original_snapshot: Option<OriginalSnapshot>,
383    /// Trace chains associated with each entity key.
384    trace_chains: std::collections::BTreeMap<EntityKey, Vec<teaql_core::TraceNode>>,
385    /// Original versions of entities to perform optimistic concurrency control.
386    original_versions: OriginalVersions,
387    /// Indicates if this entity root is entirely new.
388    is_new: bool,
389}
390
391#[derive(Debug)]
392pub struct EntityRuntimeState {
393    inner: OnceLock<Arc<Mutex<EntityMutationLedger>>>,
394    graph: EntityGraphReference,
395    loaded_snapshot: Option<teaql_core::CompactRow>,
396}
397
398#[derive(Debug)]
399enum EntityGraphReference {
400    Strong(Arc<OnceLock<FrozenEntityGraph>>),
401    Weak(Weak<OnceLock<FrozenEntityGraph>>),
402}
403
404impl EntityGraphReference {
405    fn preserve(&self) -> Self {
406        match self {
407            Self::Strong(graph) => Self::Strong(graph.clone()),
408            Self::Weak(graph) => Self::Weak(graph.clone()),
409        }
410    }
411
412    fn promote(&self) -> Self {
413        match self {
414            Self::Strong(graph) => Self::Strong(graph.clone()),
415            Self::Weak(graph) => graph
416                .upgrade()
417                .map(Self::Strong)
418                .unwrap_or_else(|| Self::Strong(Arc::default())),
419        }
420    }
421
422    fn weak(&self) -> Self {
423        match self {
424            Self::Strong(graph) => Self::Weak(Arc::downgrade(graph)),
425            Self::Weak(graph) => Self::Weak(graph.clone()),
426        }
427    }
428
429    fn pointer(&self) -> *const OnceLock<FrozenEntityGraph> {
430        match self {
431            Self::Strong(graph) => Arc::as_ptr(graph),
432            Self::Weak(graph) => graph.as_ptr(),
433        }
434    }
435
436    fn strong(&self) -> Option<&Arc<OnceLock<FrozenEntityGraph>>> {
437        match self {
438            Self::Strong(graph) => Some(graph),
439            Self::Weak(_) => None,
440        }
441    }
442
443    fn frozen(&self) -> Option<&FrozenEntityGraph> {
444        match self {
445            Self::Strong(graph) => graph.get(),
446            Self::Weak(graph) => {
447                let owner = graph.upgrade()?;
448                let frozen = owner.get()? as *const FrozenEntityGraph;
449                // SAFETY: weak graph references are only installed into entities owned by the
450                // same frozen graph. Such an entity can only be borrowed while an owning root
451                // keeps the graph alive. Cloning EntityRuntimeState promotes the weak reference to a
452                // strong owner, so an entity moved out through safe code also anchors the graph.
453                Some(unsafe { &*frozen })
454            }
455        }
456    }
457}
458
459impl Default for EntityRuntimeState {
460    fn default() -> Self {
461        Self {
462            inner: OnceLock::new(),
463            graph: EntityGraphReference::Strong(Arc::default()),
464            loaded_snapshot: None,
465        }
466    }
467}
468
469impl Clone for EntityRuntimeState {
470    fn clone(&self) -> Self {
471        let inner = OnceLock::new();
472        if let Some(context) = self.inner.get() {
473            let _ = inner.set(context.clone());
474        }
475        Self {
476            inner,
477            graph: self.graph.promote(),
478            loaded_snapshot: self.loaded_snapshot.clone(),
479        }
480    }
481}
482
483impl std::panic::UnwindSafe for EntityRuntimeState {}
484impl std::panic::RefUnwindSafe for EntityRuntimeState {}
485
486#[derive(Debug)]
487enum OriginalSnapshot {
488    Materialized(EntitySnapshot),
489    Compact(teaql_core::CompactRow),
490}
491
492impl PartialEq for EntityRuntimeState {
493    fn eq(&self, other: &Self) -> bool {
494        match (self.inner.get(), other.inner.get()) {
495            (Some(left), Some(right)) => Arc::ptr_eq(left, right),
496            (None, None) => {
497                self.graph.pointer() == other.graph.pointer()
498                    && self.loaded_snapshot == other.loaded_snapshot
499            }
500            _ => false,
501        }
502    }
503}
504
505impl EntityRuntimeState {
506    #[cfg(test)]
507    fn has_mutation_context(&self) -> bool {
508        self.inner.get().is_some()
509    }
510
511    fn context(&self) -> &Arc<Mutex<EntityMutationLedger>> {
512        self.inner
513            .get_or_init(|| Arc::new(Mutex::new(EntityMutationLedger::default())))
514    }
515
516    fn read_context<R>(&self, default: R, read: impl FnOnce(&EntityMutationLedger) -> R) -> R {
517        let Some(context) = self.inner.get() else {
518            return default;
519        };
520        let context = context.lock().unwrap_or_else(|error| error.into_inner());
521        read(&context)
522    }
523
524    fn write_context<R>(&self, write: impl FnOnce(&mut EntityMutationLedger) -> R) -> R {
525        let mut context = self
526            .context()
527            .lock()
528            .unwrap_or_else(|error| error.into_inner());
529        write(&mut context)
530    }
531
532    pub fn fresh_with_shared_graph(source: &EntityRuntimeState) -> Self {
533        Self {
534            inner: OnceLock::new(),
535            graph: source.graph.preserve(),
536            loaded_snapshot: None,
537        }
538    }
539
540    /// Create a root view for an entity stored inside the graph itself. The weak view prevents
541    /// the graph from strongly owning an entity that strongly owns the graph in return.
542    pub(crate) fn fresh_with_weak_graph(source: &EntityRuntimeState) -> Self {
543        Self {
544            inner: OnceLock::new(),
545            graph: source.graph.weak(),
546            loaded_snapshot: None,
547        }
548    }
549
550    /// Make this root resolve entities from the same flat graph as `source`.
551    /// Existing snapshots and mutation ledger state remain owned by this root.
552    pub fn with_shared_graph(&self, source: &EntityRuntimeState) -> Self {
553        Self {
554            inner: self.inner.clone(),
555            graph: source.graph.preserve(),
556            loaded_snapshot: self.loaded_snapshot.clone(),
557        }
558    }
559
560    /// Publish a completely assembled graph. It becomes immutable after this call.
561    pub fn freeze_graph(&self, builder: EntityGraphBuilder) -> Result<(), EntityGraphBuilder> {
562        let Some(graph) = self.graph.strong() else {
563            return Err(builder);
564        };
565        graph
566            .set(builder.freeze())
567            .map_err(|graph| EntityGraphBuilder {
568                tables: graph.tables,
569                relation_lists: graph.relation_lists,
570            })
571    }
572
573    /// Resolve an entity by type and ID without locking or reference cloning.
574    pub fn resolve_entity<T>(&self, id: u64) -> Option<&T>
575    where
576        T: Any + Send + Sync,
577    {
578        self.graph
579            .frozen()?
580            .tables
581            .get(&TypeId::of::<T>())?
582            .get(&id)?
583            .downcast_ref::<T>()
584    }
585
586    pub fn resolve_relation_list<T>(
587        &self,
588        owner_entity: &str,
589        owner_id: u64,
590        relation: &str,
591    ) -> Option<&SmartList<T>>
592    where
593        T: Any + Send + Sync,
594    {
595        self.graph
596            .frozen()?
597            .relation_lists
598            .get(&RelationListKey {
599                owner_entity: crate::canonical_id_space_entity(owner_entity),
600                owner_id,
601                relation: relation.to_owned(),
602            })?
603            .downcast_ref::<SmartList<T>>()
604    }
605
606    /// Resolve a to-many relation without performing an implicit database read.
607    pub fn relation_list<T>(
608        &self,
609        owner_entity: &str,
610        owner_id: u64,
611        relation: &str,
612    ) -> RelationHandle<'_, SmartList<T>>
613    where
614        T: Any + Send + Sync,
615    {
616        let Some(graph) = self.graph.frozen() else {
617            return RelationHandle::new(LoadedRelation::NotLoaded, None);
618        };
619        let key = RelationListKey {
620            owner_entity: crate::canonical_id_space_entity(owner_entity),
621            owner_id,
622            relation: relation.to_owned(),
623        };
624        let Some(stored) = graph.relation_lists.get(&key) else {
625            return RelationHandle::new(LoadedRelation::NotLoaded, None);
626        };
627        let list = stored.downcast_ref::<SmartList<T>>().unwrap_or_else(|| {
628            panic!(
629                "relation view type mismatch: owner={} id={} relation={}",
630                owner_entity, owner_id, relation
631            )
632        });
633        if list.is_empty() {
634            RelationHandle::new(LoadedRelation::Empty, Some(list))
635        } else {
636            RelationHandle::new(LoadedRelation::Loaded, Some(list))
637        }
638    }
639
640    pub fn resolve_relation_option<T>(
641        &self,
642        owner_entity: &str,
643        owner_id: u64,
644        relation: &str,
645    ) -> Option<&Option<T>>
646    where
647        T: Any + Send + Sync,
648    {
649        self.graph
650            .frozen()?
651            .relation_lists
652            .get(&RelationListKey {
653                owner_entity: crate::canonical_id_space_entity(owner_entity),
654                owner_id,
655                relation: relation.to_owned(),
656            })?
657            .downcast_ref::<Option<T>>()
658    }
659
660    /// Resolve a to-one relation without performing an implicit database read.
661    pub fn relation_option<T>(
662        &self,
663        owner_entity: &str,
664        owner_id: u64,
665        relation: &str,
666    ) -> RelationHandle<'_, T>
667    where
668        T: Any + Send + Sync,
669    {
670        let Some(graph) = self.graph.frozen() else {
671            return RelationHandle::new(LoadedRelation::NotLoaded, None);
672        };
673        let key = RelationListKey {
674            owner_entity: crate::canonical_id_space_entity(owner_entity),
675            owner_id,
676            relation: relation.to_owned(),
677        };
678        let Some(stored) = graph.relation_lists.get(&key) else {
679            return RelationHandle::new(LoadedRelation::NotLoaded, None);
680        };
681        let value = stored.downcast_ref::<Option<T>>().unwrap_or_else(|| {
682            panic!(
683                "relation view type mismatch: owner={} id={} relation={}",
684                owner_entity, owner_id, relation
685            )
686        });
687        match value {
688            Some(value) => RelationHandle::new(LoadedRelation::Loaded, Some(value)),
689            None => RelationHandle::new(LoadedRelation::Empty, None),
690        }
691    }
692
693    pub fn has_relation_view(&self, owner_entity: &str, owner_id: u64, relation: &str) -> bool {
694        self.graph.frozen().is_some_and(|graph| {
695            graph.relation_lists.contains_key(&RelationListKey {
696                owner_entity: crate::canonical_id_space_entity(owner_entity),
697                owner_id,
698                relation: relation.to_owned(),
699            })
700        })
701    }
702
703    pub fn push_change_set(&self) {
704        self.write_context(|context| context.change_sets.push());
705    }
706
707    pub fn pop_change_set(&self) -> Option<EntityChangeSet> {
708        self.inner.get()?;
709        self.write_context(|context| context.change_sets.pop())
710    }
711
712    pub fn clear_current_change_set(&self) {
713        if self.inner.get().is_some() {
714            self.write_context(|context| context.change_sets.clear_current());
715        }
716    }
717
718    /// Clear all state consumed by a successfully committed ledger save.
719    /// Failed saves must not call this method so their pending intent remains retryable.
720    pub fn clear_committed(&self) {
721        if self.inner.get().is_some() {
722            self.write_context(|context| {
723                context.change_sets = ChangeSetStack::default();
724                context.deleted_keys.clear();
725                context.new_keys.clear();
726                context.original_versions.clear();
727                context.trace_chains.clear();
728                context.original_snapshot = None;
729                context.comment = None;
730                context.is_new = false;
731            });
732        }
733    }
734
735    pub fn set(&self, key: EntityKey, field: impl Into<String>, value: impl Into<Value>) {
736        self.write_context(|context| context.change_sets.set(key, field, value.into()));
737    }
738
739    pub fn get(&self, key: &EntityKey, field: &str) -> Option<Value> {
740        self.read_context(None, |context| context.change_sets.get(key, field))
741    }
742
743    pub fn current_change_set(&self) -> EntityChangeSet {
744        self.read_context(EntityChangeSet::default(), |context| {
745            context.change_sets.current().cloned().unwrap_or_default()
746        })
747    }
748
749    /// Set an annotation comment on this entity root.
750    /// The comment propagates through the graph save process for observability.
751    pub fn set_comment(&self, comment: impl Into<String>) {
752        self.write_context(|context| context.comment = Some(comment.into()));
753    }
754
755    /// Get the annotation comment, if any.
756    pub fn get_comment(&self) -> Option<String> {
757        self.read_context(None, |context| context.comment.clone())
758    }
759
760    /// Mark this entity root as a newly created entity in memory.
761    pub fn mark_as_new(&self, key: EntityKey) {
762        self.write_context(|context| {
763            context.new_keys.insert(key);
764        });
765    }
766
767    /// Check if this entity root is marked as newly created.
768    pub fn is_new(&self, key: &EntityKey) -> bool {
769        self.read_context(false, |context| context.new_keys.contains(key))
770    }
771
772    /// Store an original loaded entity snapshot.
773    pub fn set_original_snapshot(&self, snapshot: EntitySnapshot) {
774        self.write_context(|context| {
775            context.original_snapshot = Some(OriginalSnapshot::Materialized(snapshot));
776        });
777    }
778
779    /// Store a shared-schema snapshot without allocating a mutation ledger.
780    pub fn set_original_compact_row(&mut self, row: teaql_core::CompactRow) {
781        self.loaded_snapshot = Some(row);
782    }
783
784    /// Retrieve the original loaded entity snapshot.
785    pub fn original_snapshot(&self) -> Option<EntitySnapshot> {
786        if let Some(row) = &self.loaded_snapshot {
787            return Some(EntitySnapshot::from(row.clone().into_map()));
788        }
789        self.read_context(None, |context| {
790            context
791                .original_snapshot
792                .as_ref()
793                .map(|snapshot| match snapshot {
794                    OriginalSnapshot::Materialized(snapshot) => snapshot.clone(),
795                    OriginalSnapshot::Compact(row) => EntitySnapshot::from(row.clone().into_map()),
796                })
797        })
798    }
799
800    /// Mark an entity as deleted. The next `save()` call will treat this entity
801    /// as a Remove operation in the graph save pipeline.
802    /// Any pending field changes for this entity are cleared — they are irrelevant
803    /// when the entity is being deleted.
804    pub fn mark_as_delete(&self, key: EntityKey) {
805        self.write_context(|context| {
806            context.change_sets.clear_entity(&key);
807            context.deleted_keys.insert(key);
808        });
809    }
810
811    /// Check whether an entity has been marked for deletion.
812    pub fn is_marked_as_delete(&self, key: &EntityKey) -> bool {
813        self.read_context(false, |context| context.deleted_keys.contains(key))
814    }
815
816    /// Get the set of field names that have been modified for the given entity key.
817    /// This is the Rust equivalent of Java's `entity.getUpdatedProperties()`.
818    pub fn changed_field_names(&self, key: &EntityKey) -> BTreeSet<String> {
819        self.read_context(BTreeSet::new(), |context| {
820            context.change_sets.changed_field_names(key)
821        })
822    }
823    pub fn deleted_keys(&self) -> std::collections::BTreeSet<EntityKey> {
824        self.read_context(BTreeSet::new(), |context| context.deleted_keys.clone())
825    }
826
827    pub fn new_keys(&self) -> std::collections::BTreeSet<EntityKey> {
828        self.read_context(BTreeSet::new(), |context| context.new_keys.clone())
829    }
830
831    pub fn get_original_version(&self, key: &EntityKey) -> Option<i64> {
832        self.read_context(None, |context| context.original_versions.get(key))
833            .or_else(|| {
834                self.loaded_snapshot
835                    .as_ref()?
836                    .get("id")?
837                    .try_u64()
838                    .filter(|id| Some(*id) == key.id.try_u64())?;
839                self.loaded_snapshot.as_ref()?.get("version")?.try_i64()
840            })
841    }
842
843    pub fn get_trace_chain(&self, key: &EntityKey) -> Vec<teaql_core::TraceNode> {
844        self.read_context(Vec::new(), |context| {
845            context.trace_chains.get(key).cloned().unwrap_or_default()
846        })
847    }
848
849    pub fn set_original_version(&self, key: EntityKey, version: i64) {
850        self.write_context(|context| context.original_versions.insert(key, version));
851    }
852}
853
854pub trait LedgerEntity: teaql_core::Entity {
855    fn entity_runtime_state(&self) -> Option<EntityRuntimeState>;
856}
857
858#[cfg(test)]
859mod lazy_root_tests {
860    use super::*;
861
862    #[derive(Clone)]
863    struct GraphChild {
864        root: EntityRuntimeState,
865    }
866
867    #[test]
868    fn loaded_snapshot_does_not_allocate_ledger_until_mutation() {
869        let mut root = EntityRuntimeState::default();
870        root.set_original_compact_row(teaql_core::CompactRow::new(
871            Arc::from(["id".to_owned(), "version".to_owned()]),
872            vec![Value::U64(7), Value::I64(3)],
873        ));
874        let key = EntityKey::new_static("Example", 7_u64);
875
876        assert!(!root.has_mutation_context());
877        assert_eq!(root.get(&key, "name"), None);
878        assert_eq!(root.get_original_version(&key), Some(3));
879        assert!(!root.has_mutation_context());
880
881        root.set(key, "name", Value::Text("updated".to_owned()));
882        assert!(root.has_mutation_context());
883    }
884
885    #[test]
886    fn graph_owned_entities_do_not_keep_the_graph_alive() {
887        let root = EntityRuntimeState::default();
888        let graph_owner = match &root.graph {
889            EntityGraphReference::Strong(graph) => Arc::downgrade(graph),
890            EntityGraphReference::Weak(_) => unreachable!(),
891        };
892        let mut builder = EntityGraphBuilder::default();
893        builder.install_relation_list(
894            "Owner",
895            1,
896            "children",
897            SmartList::from(vec![GraphChild {
898                root: EntityRuntimeState::fresh_with_weak_graph(&root),
899            }]),
900        );
901        root.freeze_graph(builder).unwrap();
902
903        drop(root);
904        assert!(graph_owner.upgrade().is_none());
905    }
906
907    #[test]
908    fn cloning_a_graph_owned_entity_promotes_its_graph_anchor() {
909        let root = EntityRuntimeState::default();
910        let graph_owner = match &root.graph {
911            EntityGraphReference::Strong(graph) => Arc::downgrade(graph),
912            EntityGraphReference::Weak(_) => unreachable!(),
913        };
914        let mut builder = EntityGraphBuilder::default();
915        builder.install_relation_list(
916            "Owner",
917            1,
918            "children",
919            SmartList::from(vec![GraphChild {
920                root: EntityRuntimeState::fresh_with_weak_graph(&root),
921            }]),
922        );
923        root.freeze_graph(builder).unwrap();
924        let detached = root
925            .resolve_relation_list::<GraphChild>("Owner", 1, "children")
926            .unwrap()[0]
927            .clone();
928
929        drop(root);
930        assert!(graph_owner.upgrade().is_some());
931        assert!(detached.root.graph.frozen().is_some());
932        drop(detached);
933        assert!(graph_owner.upgrade().is_none());
934    }
935
936    #[test]
937    fn relation_handles_distinguish_loaded_empty_and_not_loaded() {
938        let root = EntityRuntimeState::default();
939        let mut builder = EntityGraphBuilder::default();
940        builder.install_relation_list("Owner", 1, "loaded", SmartList::from(vec![7_u64]));
941        builder.install_relation_list::<u64>("Owner", 1, "empty", SmartList::empty());
942        builder.install_relation_option("Owner", 1, "present", Some(9_u64));
943        builder.install_relation_option::<u64>("Owner", 1, "null", None);
944        root.freeze_graph(builder).unwrap();
945
946        let loaded = root.relation_list::<u64>("Owner", 1, "loaded");
947        assert_eq!(loaded.state(), LoadedRelation::Loaded);
948        assert_eq!(loaded.value().map(|list| list.as_slice()), Some(&[7][..]));
949
950        let empty = root.relation_list::<u64>("Owner", 1, "empty");
951        assert_eq!(empty.state(), LoadedRelation::Empty);
952        assert!(empty.value().is_some_and(SmartList::is_empty));
953
954        let missing = root.relation_list::<u64>("Owner", 1, "missing");
955        assert_eq!(missing.state(), LoadedRelation::NotLoaded);
956        assert!(missing.value().is_none());
957
958        let present = root.relation_option::<u64>("Owner", 1, "present");
959        assert_eq!(present.state(), LoadedRelation::Loaded);
960        assert_eq!(present.value(), Some(&9));
961
962        let null = root.relation_option::<u64>("Owner", 1, "null");
963        assert_eq!(null.state(), LoadedRelation::Empty);
964        assert!(null.value().is_none());
965
966        let absent = root.relation_option::<u64>("Owner", 1, "absent");
967        assert_eq!(absent.state(), LoadedRelation::NotLoaded);
968        assert!(absent.value().is_none());
969    }
970}