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