Skip to main content

citadel_sql/
schema.rs

1//! Schema manager: in-memory cache of table schemas.
2
3use std::sync::Arc;
4
5use rustc_hash::{FxHashMap, FxHashSet};
6
7use citadel::{Database, SqlCacheHandle};
8use parking_lot::Mutex;
9
10use crate::error::{Result, SqlError};
11use crate::system_tables::{self, VirtualTable};
12use crate::types::{ForeignKeySchemaEntry, TableSchema, ViewDef};
13
14/// Reverse FK index (parent -> [(child table, fk idx)]), tagged with its generation.
15type FkChildrenCache = std::cell::RefCell<Option<(u64, FxHashMap<String, Vec<(String, usize)>>)>>;
16
17const SCHEMA_TABLE: &[u8] = b"_schema";
18const VIEWS_TABLE: &[u8] = b"_views";
19const TRIGGERS_TABLE: &[u8] = b"_triggers";
20const MATVIEWS_TABLE: &[u8] = b"_matviews";
21
22thread_local! {
23    /// Stack of `(alias → storage_name)` frames pushed by FOR EACH STATEMENT trigger
24    /// firings so `REFERENCING NEW TABLE AS new_t` resolves while the body runs.
25    static TRANSITION_TABLES: std::cell::RefCell<Vec<FxHashMap<String, String>>> =
26        const { std::cell::RefCell::new(Vec::new()) };
27}
28
29fn transition_table_lookup(name_lower: &str) -> Option<String> {
30    TRANSITION_TABLES.with(|cell| {
31        let stack = cell.borrow();
32        for frame in stack.iter().rev() {
33            if let Some(storage) = frame.get(name_lower) {
34                return Some(storage.clone());
35            }
36        }
37        None
38    })
39}
40
41pub(crate) fn push_transition_tables(aliases: FxHashMap<String, String>) -> TransitionGuard {
42    TRANSITION_TABLES.with(|cell| cell.borrow_mut().push(aliases));
43    TransitionGuard
44}
45
46pub(crate) struct TransitionGuard;
47impl Drop for TransitionGuard {
48    fn drop(&mut self) {
49        TRANSITION_TABLES.with(|cell| {
50            cell.borrow_mut().pop();
51        });
52    }
53}
54
55/// Manages table schemas in memory, backed by the `_schema` table.
56pub struct SchemaManager {
57    tables: FxHashMap<String, TableSchema>,
58    views: FxHashMap<String, ViewDef>,
59    virtual_tables: FxHashMap<String, Arc<dyn VirtualTable>>,
60    /// Within a `(target, timing, event)` group, triggers fire in name order.
61    triggers: FxHashMap<String, Vec<crate::types::TriggerDef>>,
62    /// Matview catalog. Backing table shares the matview's name in `tables`; this map
63    /// also gates DML rejection (matviews are read-only outside REFRESH).
64    matviews: FxHashMap<String, crate::types::MatviewDef>,
65    /// Maps user-typed TEMP name to prefixed storage name (`__temp_<conn_id>_<name>`).
66    temp_aliases: FxHashMap<String, String>,
67    /// Each entry is leaked once via `Box::leak` so `get()` can hand out a `&TableSchema`
68    /// from inside `&self` methods. Bounded by `(active triggers × transition aliases)`.
69    transition_schemas: std::cell::RefCell<FxHashMap<String, &'static TableSchema>>,
70    generation: u64,
71    /// Per-Database shared cache (e.g. ANN indexes). Cloned from the Database
72    /// when the Connection opens; all Connections to the same DB share entries.
73    /// Tests created via `empty()` get their own isolated cache.
74    pub sql_caches: SqlCacheHandle,
75    /// Tables mutated (UPDATE/DELETE/upsert/DDL) since the last commit; their
76    /// shared caches are hard-invalidated on commit.
77    dml_dirty_tables: std::cell::RefCell<FxHashSet<String>>,
78    /// Tables touched only by pure appends, mapped to the min inserted pk; an
79    /// append above the index snapshot tail-merges instead of hard-invalidating.
80    dml_append_tables: std::cell::RefCell<FxHashMap<String, i64>>,
81    /// Reverse FK index, rebuilt lazily whenever the schema generation changes.
82    fk_children_cache: FkChildrenCache,
83}
84
85/// DML since the last commit, classified for cache invalidation.
86pub struct DmlDirty {
87    pub mutating: Vec<String>,
88    pub appends: Vec<(String, i64)>,
89}
90
91#[derive(Clone)]
92pub struct SchemaSnapshot {
93    tables: FxHashMap<String, TableSchema>,
94    views: FxHashMap<String, ViewDef>,
95    generation: u64,
96}
97
98impl SchemaManager {
99    pub fn empty() -> Self {
100        Self {
101            tables: FxHashMap::default(),
102            views: FxHashMap::default(),
103            virtual_tables: FxHashMap::default(),
104            triggers: FxHashMap::default(),
105            matviews: FxHashMap::default(),
106            temp_aliases: FxHashMap::default(),
107            transition_schemas: std::cell::RefCell::new(FxHashMap::default()),
108            generation: 0,
109            sql_caches: Arc::new(Mutex::new(FxHashMap::default())),
110            dml_dirty_tables: std::cell::RefCell::new(FxHashSet::default()),
111            dml_append_tables: std::cell::RefCell::new(FxHashMap::default()),
112            fk_children_cache: std::cell::RefCell::new(None),
113        }
114    }
115
116    /// Mark a table mutated (UPDATE/DELETE/upsert/DDL); supersedes a pending append.
117    pub fn mark_dml(&self, table_name: &str) {
118        let lower = table_name.to_ascii_lowercase();
119        self.dml_append_tables.borrow_mut().remove(&lower);
120        self.dml_dirty_tables.borrow_mut().insert(lower);
121    }
122
123    /// Mark a pure append with the smallest inserted pk; no-op if already mutating.
124    pub fn mark_dml_append(&self, table_name: &str, min_pk: i64) {
125        let lower = table_name.to_ascii_lowercase();
126        if self.dml_dirty_tables.borrow().contains(&lower) {
127            return;
128        }
129        self.dml_append_tables
130            .borrow_mut()
131            .entry(lower)
132            .and_modify(|m| *m = (*m).min(min_pk))
133            .or_insert(min_pk);
134    }
135
136    /// Take the touched tables, classified into mutating vs pure-append.
137    pub fn drain_dml_dirty(&self) -> DmlDirty {
138        DmlDirty {
139            mutating: self.dml_dirty_tables.borrow_mut().drain().collect(),
140            appends: self.dml_append_tables.borrow_mut().drain().collect(),
141        }
142    }
143
144    pub fn has_dml_dirty(&self) -> bool {
145        !self.dml_dirty_tables.borrow().is_empty() || !self.dml_append_tables.borrow().is_empty()
146    }
147
148    /// Forget pending DML markers without invalidating downstream caches.
149    /// Used on rollback (uncommitted writes leave no caches stale).
150    pub fn clear_dml_dirty(&self) {
151        self.dml_dirty_tables.borrow_mut().clear();
152        self.dml_append_tables.borrow_mut().clear();
153    }
154
155    pub fn register_temp_alias(&mut self, user_name: &str, prefixed_name: String) {
156        self.temp_aliases
157            .insert(user_name.to_ascii_lowercase(), prefixed_name);
158        self.generation += 1;
159    }
160
161    pub fn unregister_temp_alias(&mut self, user_name: &str) -> Option<String> {
162        let lower = user_name.to_ascii_lowercase();
163        let removed = self.temp_aliases.remove(&lower);
164        if removed.is_some() {
165            self.generation += 1;
166        }
167        removed
168    }
169
170    pub fn temp_alias_iter(&self) -> impl Iterator<Item = (&str, &str)> + '_ {
171        self.temp_aliases
172            .iter()
173            .map(|(k, v)| (k.as_str(), v.as_str()))
174    }
175
176    pub fn resolve_temp(&self, name: &str) -> String {
177        let lower = name.to_ascii_lowercase();
178        if let Some(prefixed) = self.temp_aliases.get(&lower) {
179            return prefixed.clone();
180        }
181        name.to_string()
182    }
183
184    pub fn load(db: &Database) -> Result<Self> {
185        let mut tables = FxHashMap::default();
186
187        let mut rtx = db.begin_read();
188        let mut parse_err: Option<crate::error::SqlError> = None;
189        let scan_result = rtx.table_for_each(SCHEMA_TABLE, |_key, value| {
190            match TableSchema::deserialize(value) {
191                Ok(schema) => {
192                    tables.insert(schema.name.clone(), schema);
193                }
194                Err(e) => {
195                    parse_err = Some(e);
196                }
197            }
198            Ok(())
199        });
200
201        match scan_result {
202            Ok(()) => {}
203            Err(citadel_core::Error::TableNotFound(_)) => {}
204            Err(e) => return Err(e.into()),
205        }
206        if let Some(e) = parse_err {
207            return Err(e);
208        }
209
210        let mut views = FxHashMap::default();
211        let mut rtx2 = db.begin_read();
212        let mut view_err: Option<crate::error::SqlError> = None;
213        let view_scan = rtx2.table_for_each(VIEWS_TABLE, |_key, value| {
214            match ViewDef::deserialize(value) {
215                Ok(vd) => {
216                    views.insert(vd.name.clone(), vd);
217                }
218                Err(e) => {
219                    view_err = Some(e);
220                }
221            }
222            Ok(())
223        });
224
225        match view_scan {
226            Ok(()) => {}
227            Err(citadel_core::Error::TableNotFound(_)) => {}
228            Err(e) => return Err(e.into()),
229        }
230        if let Some(e) = view_err {
231            return Err(e);
232        }
233
234        let mut triggers: FxHashMap<String, Vec<crate::types::TriggerDef>> = FxHashMap::default();
235        let mut rtx3 = db.begin_read();
236        let mut trig_err: Option<crate::error::SqlError> = None;
237        let trig_scan = rtx3.table_for_each(TRIGGERS_TABLE, |_key, value| {
238            match crate::types::TriggerDef::deserialize(value) {
239                Ok(td) => {
240                    triggers
241                        .entry(td.target.to_ascii_lowercase())
242                        .or_default()
243                        .push(td);
244                }
245                Err(e) => {
246                    trig_err = Some(e);
247                }
248            }
249            Ok(())
250        });
251        match trig_scan {
252            Ok(()) => {}
253            Err(citadel_core::Error::TableNotFound(_)) => {}
254            Err(e) => return Err(e.into()),
255        }
256        if let Some(e) = trig_err {
257            return Err(e);
258        }
259        // PG-faithful: triggers fire in name order within a (target, timing, event) group.
260        for v in triggers.values_mut() {
261            v.sort_by(|a, b| a.name.cmp(&b.name));
262        }
263
264        let mut matviews: FxHashMap<String, crate::types::MatviewDef> = FxHashMap::default();
265        let mut rtx4 = db.begin_read();
266        let mut mv_err: Option<crate::error::SqlError> = None;
267        let mv_scan = rtx4.table_for_each(MATVIEWS_TABLE, |_key, value| {
268            match crate::types::MatviewDef::deserialize(value) {
269                Ok(mv) => {
270                    matviews.insert(mv.name.to_ascii_lowercase(), mv);
271                }
272                Err(e) => {
273                    mv_err = Some(e);
274                }
275            }
276            Ok(())
277        });
278        match mv_scan {
279            Ok(()) => {}
280            Err(citadel_core::Error::TableNotFound(_)) => {}
281            Err(e) => return Err(e.into()),
282        }
283        if let Some(e) = mv_err {
284            return Err(e);
285        }
286
287        let mut mgr = Self {
288            tables,
289            views,
290            virtual_tables: FxHashMap::default(),
291            triggers,
292            matviews,
293            temp_aliases: FxHashMap::default(),
294            transition_schemas: std::cell::RefCell::new(FxHashMap::default()),
295            generation: 0,
296            sql_caches: db.sql_cache_handle(),
297            dml_dirty_tables: std::cell::RefCell::new(FxHashSet::default()),
298            dml_append_tables: std::cell::RefCell::new(FxHashMap::default()),
299            fk_children_cache: std::cell::RefCell::new(None),
300        };
301        system_tables::register_builtins(&mut mgr);
302        Ok(mgr)
303    }
304
305    pub fn get_virtual(&self, name: &str) -> Option<&Arc<dyn VirtualTable>> {
306        self.virtual_tables.get(name)
307    }
308
309    pub fn register_virtual(&mut self, vt: Arc<dyn VirtualTable>) {
310        let name = vt.name().to_ascii_lowercase();
311        self.virtual_tables.insert(name, vt);
312    }
313
314    pub fn get(&self, name: &str) -> Option<&TableSchema> {
315        let lower = name.to_ascii_lowercase();
316        if let Some(prefixed) = transition_table_lookup(&lower) {
317            if let Some(s) = self.tables.get(&prefixed) {
318                return Some(s);
319            }
320            if let Some(&leaked) = self.transition_schemas.borrow().get(&prefixed) {
321                return Some(leaked);
322            }
323        }
324        if let Some(mv) = self.matviews.get(&lower) {
325            return self.tables.get(&mv.backing_table);
326        }
327        if let Some(prefixed) = self.temp_aliases.get(&lower) {
328            return self.tables.get(prefixed);
329        }
330        if let Some(s) = self.tables.get(name) {
331            return Some(s);
332        }
333        if name.bytes().any(|b| b.is_ascii_uppercase()) {
334            self.tables.get(&lower)
335        } else {
336            None
337        }
338    }
339
340    pub fn register_transition_schema(&self, storage_name: String, schema: TableSchema) {
341        let leaked: &'static TableSchema = Box::leak(Box::new(schema));
342        self.transition_schemas
343            .borrow_mut()
344            .insert(storage_name, leaked);
345    }
346
347    pub fn unregister_transition_schema(&self, storage_name: &str) {
348        self.transition_schemas.borrow_mut().remove(storage_name);
349    }
350
351    pub fn contains(&self, name: &str) -> bool {
352        let lower = name.to_ascii_lowercase();
353        if transition_table_lookup(&lower).is_some() {
354            return true;
355        }
356        if self.matviews.contains_key(&lower) {
357            return true;
358        }
359        if self.temp_aliases.contains_key(&lower) {
360            return true;
361        }
362        if self.tables.contains_key(name) {
363            return true;
364        }
365        if name.bytes().any(|b| b.is_ascii_uppercase()) {
366            self.tables.contains_key(&lower)
367        } else {
368            false
369        }
370    }
371
372    pub fn generation(&self) -> u64 {
373        self.generation
374    }
375
376    pub fn register(&mut self, schema: TableSchema) {
377        let lower = schema.name.to_ascii_lowercase();
378        self.tables.insert(lower, schema);
379        self.generation += 1;
380    }
381
382    pub fn remove(&mut self, name: &str) -> Option<TableSchema> {
383        let lower = name.to_ascii_lowercase();
384        let result = self.tables.remove(&lower);
385        if result.is_some() {
386            self.generation += 1;
387        }
388        result
389    }
390
391    pub fn table_names(&self) -> Vec<&str> {
392        self.tables.keys().map(|s| s.as_str()).collect()
393    }
394
395    pub fn all_schemas(&self) -> impl Iterator<Item = &TableSchema> {
396        self.tables.values()
397    }
398
399    pub fn get_view(&self, name: &str) -> Option<&ViewDef> {
400        if let Some(v) = self.views.get(name) {
401            return Some(v);
402        }
403        if name.bytes().any(|b| b.is_ascii_uppercase()) {
404            self.views.get(&name.to_ascii_lowercase())
405        } else {
406            None
407        }
408    }
409
410    pub fn register_view(&mut self, view: ViewDef) {
411        let lower = view.name.to_ascii_lowercase();
412        self.views.insert(lower, view);
413        self.generation += 1;
414    }
415
416    pub fn remove_view(&mut self, name: &str) -> Option<ViewDef> {
417        let lower = name.to_ascii_lowercase();
418        let result = self.views.remove(&lower);
419        if result.is_some() {
420            self.generation += 1;
421        }
422        result
423    }
424
425    pub fn view_names(&self) -> Vec<&str> {
426        self.views.keys().map(|s| s.as_str()).collect()
427    }
428
429    pub fn triggers_for(&self, target: &str) -> &[crate::types::TriggerDef] {
430        if self.triggers.is_empty() {
431            return &[];
432        }
433        // Keys are stored lowercased; skip the alloc when target already is.
434        if !target.bytes().any(|b| b.is_ascii_uppercase()) {
435            return self.triggers.get(target).map_or(&[], |v| v.as_slice());
436        }
437        let key = target.to_ascii_lowercase();
438        self.triggers.get(&key).map_or(&[], |v| v.as_slice())
439    }
440
441    pub fn all_triggers(&self) -> impl Iterator<Item = &crate::types::TriggerDef> + '_ {
442        self.triggers.values().flatten()
443    }
444
445    pub fn register_trigger(&mut self, trig: crate::types::TriggerDef) {
446        let target = trig.target.to_ascii_lowercase();
447        let bucket = self.triggers.entry(target).or_default();
448        bucket.push(trig);
449        bucket.sort_by(|a, b| a.name.cmp(&b.name));
450        self.generation += 1;
451    }
452
453    pub fn remove_trigger(&mut self, name: &str) -> Option<crate::types::TriggerDef> {
454        let lower = name.to_ascii_lowercase();
455        let mut result = None;
456        for bucket in self.triggers.values_mut() {
457            if let Some(pos) = bucket
458                .iter()
459                .position(|t| t.name.eq_ignore_ascii_case(&lower))
460            {
461                result = Some(bucket.remove(pos));
462                break;
463            }
464        }
465        if result.is_some() {
466            self.generation += 1;
467        }
468        result
469    }
470
471    /// Caller is responsible for dropping the returned triggers' on-disk catalog rows.
472    pub fn remove_triggers_for(&mut self, target: &str) -> Vec<crate::types::TriggerDef> {
473        let key = target.to_ascii_lowercase();
474        let removed = self.triggers.remove(&key).unwrap_or_default();
475        if !removed.is_empty() {
476            self.generation += 1;
477        }
478        removed
479    }
480
481    pub fn find_trigger(&self, name: &str) -> Option<(&str, &crate::types::TriggerDef)> {
482        let lower = name.to_ascii_lowercase();
483        for (target, bucket) in &self.triggers {
484            if let Some(t) = bucket.iter().find(|t| t.name.eq_ignore_ascii_case(&lower)) {
485                return Some((target.as_str(), t));
486            }
487        }
488        None
489    }
490
491    pub fn set_trigger_enabled(&mut self, name: &str, enabled: bool) -> bool {
492        let lower = name.to_ascii_lowercase();
493        for bucket in self.triggers.values_mut() {
494            if let Some(t) = bucket
495                .iter_mut()
496                .find(|t| t.name.eq_ignore_ascii_case(&lower))
497            {
498                t.enabled = enabled;
499                self.generation += 1;
500                return true;
501            }
502        }
503        false
504    }
505
506    pub fn set_all_triggers_enabled(&mut self, target: &str, enabled: bool) -> usize {
507        let key = target.to_ascii_lowercase();
508        let bucket = match self.triggers.get_mut(&key) {
509            Some(b) => b,
510            None => return 0,
511        };
512        let count = bucket.len();
513        for t in bucket {
514            t.enabled = enabled;
515        }
516        if count > 0 {
517            self.generation += 1;
518        }
519        count
520    }
521
522    pub fn ensure_triggers_table(wtx: &mut citadel_txn::write_txn::WriteTxn<'_>) -> Result<()> {
523        match wtx.create_table(TRIGGERS_TABLE) {
524            Ok(()) => Ok(()),
525            Err(citadel_core::Error::TableAlreadyExists(_)) => Ok(()),
526            Err(e) => Err(e.into()),
527        }
528    }
529
530    pub fn save_trigger(
531        wtx: &mut citadel_txn::write_txn::WriteTxn<'_>,
532        trig: &crate::types::TriggerDef,
533    ) -> Result<()> {
534        Self::ensure_triggers_table(wtx)?;
535        let data = trig.serialize();
536        let lower = trig.name.to_ascii_lowercase();
537        wtx.table_insert(TRIGGERS_TABLE, lower.as_bytes(), &data)
538            .map_err(crate::error::SqlError::from)?;
539        Ok(())
540    }
541
542    pub fn delete_trigger(
543        wtx: &mut citadel_txn::write_txn::WriteTxn<'_>,
544        name: &str,
545    ) -> Result<()> {
546        Self::ensure_triggers_table(wtx)?;
547        let lower = name.to_ascii_lowercase();
548        wtx.table_delete(TRIGGERS_TABLE, lower.as_bytes())
549            .map_err(crate::error::SqlError::from)?;
550        Ok(())
551    }
552
553    pub fn save_view(wtx: &mut citadel_txn::write_txn::WriteTxn<'_>, view: &ViewDef) -> Result<()> {
554        let lower = view.name.to_ascii_lowercase();
555        let data = view.serialize();
556        wtx.table_insert(VIEWS_TABLE, lower.as_bytes(), &data)?;
557        Ok(())
558    }
559
560    pub fn delete_view(wtx: &mut citadel_txn::write_txn::WriteTxn<'_>, name: &str) -> Result<()> {
561        let lower = name.to_ascii_lowercase();
562        wtx.table_delete(VIEWS_TABLE, lower.as_bytes())
563            .map_err(|e| match e {
564                citadel_core::Error::TableNotFound(_) => SqlError::ViewNotFound(name.into()),
565                other => SqlError::Storage(other),
566            })?;
567        Ok(())
568    }
569
570    pub fn ensure_views_table(wtx: &mut citadel_txn::write_txn::WriteTxn<'_>) -> Result<()> {
571        match wtx.create_table(VIEWS_TABLE) {
572            Ok(()) => Ok(()),
573            Err(citadel_core::Error::TableAlreadyExists(_)) => Ok(()),
574            Err(e) => Err(e.into()),
575        }
576    }
577
578    pub fn get_matview(&self, name: &str) -> Option<&crate::types::MatviewDef> {
579        let lower = name.to_ascii_lowercase();
580        self.matviews.get(&lower)
581    }
582
583    pub fn matview_names(&self) -> Vec<&str> {
584        self.matviews.keys().map(|s| s.as_str()).collect()
585    }
586
587    pub fn all_matviews(&self) -> impl Iterator<Item = &crate::types::MatviewDef> + '_ {
588        self.matviews.values()
589    }
590
591    pub fn register_matview(&mut self, mv: crate::types::MatviewDef) {
592        let lower = mv.name.to_ascii_lowercase();
593        self.matviews.insert(lower, mv);
594        self.generation += 1;
595    }
596
597    pub fn remove_matview(&mut self, name: &str) -> Option<crate::types::MatviewDef> {
598        let lower = name.to_ascii_lowercase();
599        let removed = self.matviews.remove(&lower);
600        if removed.is_some() {
601            self.generation += 1;
602        }
603        removed
604    }
605
606    pub fn ensure_matviews_table(wtx: &mut citadel_txn::write_txn::WriteTxn<'_>) -> Result<()> {
607        match wtx.create_table(MATVIEWS_TABLE) {
608            Ok(()) => Ok(()),
609            Err(citadel_core::Error::TableAlreadyExists(_)) => Ok(()),
610            Err(e) => Err(e.into()),
611        }
612    }
613
614    pub fn save_matview(
615        wtx: &mut citadel_txn::write_txn::WriteTxn<'_>,
616        mv: &crate::types::MatviewDef,
617    ) -> Result<()> {
618        Self::ensure_matviews_table(wtx)?;
619        let lower = mv.name.to_ascii_lowercase();
620        let data = mv.serialize();
621        wtx.table_insert(MATVIEWS_TABLE, lower.as_bytes(), &data)?;
622        Ok(())
623    }
624
625    pub fn delete_matview(
626        wtx: &mut citadel_txn::write_txn::WriteTxn<'_>,
627        name: &str,
628    ) -> Result<()> {
629        Self::ensure_matviews_table(wtx)?;
630        let lower = name.to_ascii_lowercase();
631        wtx.table_delete(MATVIEWS_TABLE, lower.as_bytes())
632            .map_err(crate::error::SqlError::from)?;
633        Ok(())
634    }
635
636    pub fn child_fks_for(&self, parent: &str) -> Vec<(&str, &ForeignKeySchemaEntry)> {
637        self.ensure_fk_children_cache();
638        let cache = self.fk_children_cache.borrow();
639        let Some((_, map)) = cache.as_ref() else {
640            return Vec::new();
641        };
642        let Some(children) = map.get(parent) else {
643            return Vec::new();
644        };
645        children
646            .iter()
647            .map(|(child, fk_idx)| {
648                let schema = self.tables.get(child).expect("cached child table exists");
649                (schema.name.as_str(), &schema.foreign_keys[*fk_idx])
650            })
651            .collect()
652    }
653
654    /// Rebuild the reverse FK index iff it is stale for the current generation.
655    fn ensure_fk_children_cache(&self) {
656        if matches!(self.fk_children_cache.borrow().as_ref(), Some((g, _)) if *g == self.generation)
657        {
658            return;
659        }
660        let mut map: FxHashMap<String, Vec<(String, usize)>> = FxHashMap::default();
661        for (child_name, schema) in &self.tables {
662            for (fk_idx, fk) in schema.foreign_keys.iter().enumerate() {
663                map.entry(fk.foreign_table.clone())
664                    .or_default()
665                    .push((child_name.clone(), fk_idx));
666            }
667        }
668        *self.fk_children_cache.borrow_mut() = Some((self.generation, map));
669    }
670
671    pub fn save_schema(
672        wtx: &mut citadel_txn::write_txn::WriteTxn<'_>,
673        schema: &TableSchema,
674    ) -> Result<()> {
675        let lower = schema.name.to_ascii_lowercase();
676        let data = schema.serialize();
677        wtx.table_insert(SCHEMA_TABLE, lower.as_bytes(), &data)?;
678        Ok(())
679    }
680
681    pub fn delete_schema(wtx: &mut citadel_txn::write_txn::WriteTxn<'_>, name: &str) -> Result<()> {
682        let lower = name.to_ascii_lowercase();
683        wtx.table_delete(SCHEMA_TABLE, lower.as_bytes())
684            .map_err(|e| match e {
685                citadel_core::Error::TableNotFound(_) => SqlError::TableNotFound(name.into()),
686                other => SqlError::Storage(other),
687            })?;
688        Ok(())
689    }
690
691    pub fn ensure_schema_table(wtx: &mut citadel_txn::write_txn::WriteTxn<'_>) -> Result<()> {
692        match wtx.create_table(SCHEMA_TABLE) {
693            Ok(()) => Ok(()),
694            Err(citadel_core::Error::TableAlreadyExists(_)) => Ok(()),
695            Err(e) => Err(e.into()),
696        }
697    }
698
699    pub fn save_snapshot(&self) -> SchemaSnapshot {
700        SchemaSnapshot {
701            tables: self.tables.clone(),
702            views: self.views.clone(),
703            generation: self.generation,
704        }
705    }
706
707    pub fn restore_snapshot(&mut self, snap: SchemaSnapshot) {
708        self.tables = snap.tables;
709        self.views = snap.views;
710        self.generation = snap.generation;
711    }
712}
713
714#[cfg(test)]
715#[path = "schema_tests.rs"]
716mod tests;