1use crate::ast::identifiers::ObjectId;
2use crate::model::constraint::ConstraintState;
3use crate::model::function::FunctionState;
4use crate::model::relation::{RelationKind, RelationState};
5use crate::model::replication::{PublicationState, SubscriptionState};
6use crate::model::role::RoleState;
7use crate::model::schema::SchemaState;
8use crate::model::sequence::SequenceState;
9use crate::model::trigger::TriggerEnableMode;
10use crate::model::types::TypeState;
11use serde::{Deserialize, Serialize};
12use std::collections::{HashMap, HashSet};
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15pub struct ForeignKeyCache {
16 pub constraint_name: String,
17 pub from_table: ObjectId,
18 pub to_table: ObjectId,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub struct IndexCache {
23 pub index_id: ObjectId,
24 pub table_id: ObjectId,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct TriggerCache {
29 pub trigger_id: ObjectId,
30 pub table_id: ObjectId,
31 pub function_id: ObjectId,
32 pub enabled_mode: TriggerEnableMode,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct DependencyCache {
37 pub classid: u32,
38 pub objid: u32,
39 pub objsubid: i32,
40 pub refclassid: u32,
41 pub refobjid: u32,
42 pub refobjsubid: i32,
43 pub deptype: String,
44 pub obj_schema: Option<String>,
45 pub obj_name: Option<String>,
46 pub ref_schema: Option<String>,
47 pub ref_name: Option<String>,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize, Default)]
51pub struct CacheMetadata {
52 pub created_at_unix_secs: Option<u64>,
55 pub source_database: Option<String>,
58 pub source_role: Option<String>,
61 pub source_session_role: Option<String>,
64 pub source_search_path: Option<Vec<String>>,
66 pub source_lock_timeout_ms: u64,
70 pub source_statement_timeout_ms: u64,
74 pub schemas: Option<Vec<String>>,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct DbCache {
81 pub pg_version_num: Option<u32>,
82 pub metadata: CacheMetadata,
83 pub search_path: Vec<String>,
84 pub relations: HashMap<ObjectId, RelationState>,
85 pub foreign_keys: Vec<ForeignKeyCache>,
86 pub indexes: Vec<IndexCache>,
87 pub constraints: Vec<ConstraintState>,
88 pub triggers: Vec<TriggerCache>,
89 pub functions: HashMap<ObjectId, FunctionState>,
90 pub types: HashMap<ObjectId, TypeState>,
91 pub roles: HashMap<ObjectId, RoleState>,
92 pub schemas: HashMap<String, SchemaState>,
93 pub sequences: HashMap<ObjectId, SequenceState>,
94 pub dependencies: Vec<DependencyCache>,
95 pub publications: HashMap<String, PublicationState>,
96 pub subscriptions: HashMap<String, SubscriptionState>,
97}
98
99pub const CACHE_FORMAT_VERSION: u32 = 6;
100
101pub const CACHE_V6_MAGIC: &[u8] = b"SMCACHE06";
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub enum DbCacheVersioned {
105 V1,
109 V2,
110 V3,
111 V4,
112 V5(Box<DbCache>),
113 V6(Box<DbCache>),
114}
115
116impl DbCacheVersioned {
117 pub fn format_version(&self) -> u32 {
118 match self {
119 DbCacheVersioned::V1 => 1,
120 DbCacheVersioned::V2 => 2,
121 DbCacheVersioned::V3 => 3,
122 DbCacheVersioned::V4 => 4,
123 DbCacheVersioned::V5(_) => 5,
124 DbCacheVersioned::V6(_) => 6,
125 }
126 }
127
128 pub fn into_cache(self) -> Result<DbCache, String> {
129 match self {
130 DbCacheVersioned::V1
131 | DbCacheVersioned::V2
132 | DbCacheVersioned::V3
133 | DbCacheVersioned::V4
134 | DbCacheVersioned::V5(_) => Err(
135 "This cache format is unsupported. Run `safe-migrate sync` to rebuild it."
136 .to_string(),
137 ),
138 DbCacheVersioned::V6(c) => {
139 c.validate_semantics()?;
140 Ok(*c)
141 }
142 }
143 }
144}
145
146impl Default for DbCache {
147 fn default() -> Self {
148 Self::new()
149 }
150}
151
152impl DbCache {
153 pub fn new() -> Self {
154 Self {
155 pg_version_num: None,
156 metadata: CacheMetadata::default(),
157 search_path: vec!["public".to_string()],
158 relations: HashMap::new(),
159 foreign_keys: Vec::new(),
160 indexes: Vec::new(),
161 constraints: Vec::new(),
162 triggers: Vec::new(),
163 functions: HashMap::new(),
164 types: HashMap::new(),
165 roles: HashMap::new(),
166 schemas: HashMap::new(),
167 sequences: HashMap::new(),
168 dependencies: Vec::new(),
169 publications: HashMap::new(),
170 subscriptions: HashMap::new(),
171 }
172 }
173
174 pub fn insert_baseline(&mut self, id: ObjectId, state: RelationState) {
175 self.relations.insert(id, state);
176 }
177
178 pub fn baseline_relations(&self) -> impl Iterator<Item = (&ObjectId, &RelationState)> {
179 self.relations.iter()
180 }
181
182 pub(crate) fn validate_semantics(&self) -> Result<(), String> {
183 for (id, relation) in &self.relations {
184 if id != &relation.id {
185 return Err(format!(
186 "relation cache key '{}' disagrees with embedded identity '{}'",
187 id, relation.id
188 ));
189 }
190 }
191 for (id, function) in &self.functions {
192 if id != &function.id {
193 return Err(format!(
194 "routine cache key '{}' disagrees with embedded identity '{}'",
195 id, function.id
196 ));
197 }
198 }
199 for (id, ty) in &self.types {
200 if id != &ty.id {
201 return Err(format!(
202 "type cache key '{}' disagrees with embedded identity '{}'",
203 id, ty.id
204 ));
205 }
206 }
207 for (id, role) in &self.roles {
208 if id != &role.id {
209 return Err(format!(
210 "role cache key '{}' disagrees with embedded identity '{}'",
211 id, role.id
212 ));
213 }
214 }
215 for (name, schema) in &self.schemas {
216 if name != &schema.name {
217 return Err(format!(
218 "schema cache key '{}' disagrees with embedded identity '{}'",
219 name, schema.name
220 ));
221 }
222 }
223 for (id, sequence) in &self.sequences {
224 if id != &sequence.id {
225 return Err(format!(
226 "sequence cache key '{}' disagrees with embedded identity '{}'",
227 id, sequence.id
228 ));
229 }
230 }
231 for (name, publication) in &self.publications {
232 if name != &publication.name {
233 return Err(format!(
234 "publication cache key '{}' disagrees with embedded identity '{}'",
235 name, publication.name
236 ));
237 }
238 }
239 for (name, subscription) in &self.subscriptions {
240 if name != &subscription.name {
241 return Err(format!(
242 "subscription cache key '{}' disagrees with embedded identity '{}'",
243 name, subscription.name
244 ));
245 }
246 }
247
248 for schema in &self.search_path {
249 if !self.schemas.is_empty() && !self.schemas.contains_key(schema) {
250 return Err(format!(
251 "effective search path references missing schema '{}'",
252 schema
253 ));
254 }
255 }
256
257 for (id, sequence) in &self.sequences {
258 if let Some((table_id, column_name)) = &sequence.owned_by {
259 let Some(relation) = self.relations.get(table_id) else {
260 let omitted_owner_schema =
261 self.metadata.schemas.as_ref().is_some_and(|schemas| {
262 !schemas.iter().any(|schema| schema == &table_id.schema)
263 });
264 if omitted_owner_schema {
265 continue;
266 }
267 return Err(format!(
268 "sequence '{}' ownership references missing relation '{}'",
269 id, table_id
270 ));
271 };
272 if !matches!(relation.kind, RelationKind::Table) {
273 return Err(format!(
274 "sequence '{}' ownership target '{}' is not a table",
275 id, table_id
276 ));
277 }
278 if !relation.has_column(column_name) {
279 return Err(format!(
280 "sequence '{}' ownership references missing column '{}.{}'",
281 id, table_id, column_name
282 ));
283 }
284 }
285 }
286
287 for (id, role) in &self.roles {
288 for target in role.member_of.iter().chain(&role.can_set_role_to) {
289 if !self.roles.contains_key(target) {
290 return Err(format!(
291 "role '{}' membership references missing role '{}'",
292 id, target
293 ));
294 }
295 }
296 }
297
298 let mut constraint_keys = HashSet::new();
299 for constraint in &self.constraints {
300 let Some(relation) = self.relations.get(&constraint.table_id) else {
301 return Err(format!(
302 "constraint '{}.{}' references a missing relation",
303 constraint.table_id, constraint.name
304 ));
305 };
306 if !matches!(relation.kind, RelationKind::Table) {
307 return Err(format!(
308 "constraint '{}.{}' targets a non-table relation",
309 constraint.table_id, constraint.name
310 ));
311 }
312 if !constraint_keys.insert((constraint.table_id.clone(), constraint.name.clone())) {
313 return Err(format!(
314 "constraint '{}.{}' appears more than once",
315 constraint.table_id, constraint.name
316 ));
317 }
318 }
319
320 let mut index_ids = HashSet::new();
321 for index in &self.indexes {
322 let Some(relation) = self.relations.get(&index.table_id) else {
323 return Err(format!(
324 "index '{}' references missing relation '{}'",
325 index.index_id, index.table_id
326 ));
327 };
328 if !matches!(
329 relation.kind,
330 RelationKind::Table | RelationKind::MaterializedView
331 ) {
332 return Err(format!(
333 "index '{}' targets a non-indexable relation '{}'",
334 index.index_id, index.table_id
335 ));
336 }
337 if !index_ids.insert(index.index_id.clone()) {
338 return Err(format!("index '{}' appears more than once", index.index_id));
339 }
340 }
341
342 let mut trigger_ids = HashSet::new();
343 for trigger in &self.triggers {
344 let Some(relation) = self.relations.get(&trigger.table_id) else {
345 return Err(format!(
346 "trigger '{}' references missing relation '{}'",
347 trigger.trigger_id, trigger.table_id
348 ));
349 };
350 if !matches!(relation.kind, RelationKind::Table | RelationKind::View) {
351 return Err(format!(
352 "trigger '{}' targets a relation kind that cannot have triggers",
353 trigger.trigger_id
354 ));
355 }
356 if !trigger_ids.insert(trigger.trigger_id.clone()) {
357 return Err(format!(
358 "trigger '{}' appears more than once",
359 trigger.trigger_id
360 ));
361 }
362 }
363
364 let mut foreign_key_ids = HashSet::new();
365 for foreign_key in &self.foreign_keys {
366 let Some(from_relation) = self.relations.get(&foreign_key.from_table) else {
367 return Err(format!(
368 "foreign key '{}.{}' references a missing relation",
369 foreign_key.from_table, foreign_key.constraint_name
370 ));
371 };
372 let Some(to_relation) = self.relations.get(&foreign_key.to_table) else {
373 return Err(format!(
374 "foreign key '{}.{}' references a missing relation",
375 foreign_key.from_table, foreign_key.constraint_name
376 ));
377 };
378 if !matches!(from_relation.kind, RelationKind::Table)
379 || !matches!(to_relation.kind, RelationKind::Table)
380 {
381 return Err(format!(
382 "foreign key '{}.{}' must reference tables",
383 foreign_key.from_table, foreign_key.constraint_name
384 ));
385 }
386 if !foreign_key_ids.insert((
387 foreign_key.from_table.clone(),
388 foreign_key.constraint_name.clone(),
389 )) {
390 return Err(format!(
391 "foreign key '{}.{}' appears more than once",
392 foreign_key.from_table, foreign_key.constraint_name
393 ));
394 }
395 if !self.constraints.iter().any(|constraint| {
396 constraint.table_id == foreign_key.from_table
397 && constraint.name == foreign_key.constraint_name
398 && matches!(
399 constraint.kind,
400 crate::model::constraint::ConstraintKind::ForeignKey
401 )
402 }) {
403 return Err(format!(
404 "foreign key '{}.{}' has no matching constraint",
405 foreign_key.from_table, foreign_key.constraint_name
406 ));
407 }
408 }
409
410 for dependency in &self.dependencies {
411 if dependency.deptype != "view" {
412 continue;
416 }
417 if dependency
418 .obj_schema
419 .as_deref()
420 .is_some_and(|schema| schema == "information_schema" || schema.starts_with("pg_"))
421 || dependency.ref_schema.as_deref().is_some_and(|schema| {
422 schema == "information_schema" || schema.starts_with("pg_")
423 })
424 {
425 continue;
428 }
429 let object_id = dependency
430 .obj_schema
431 .as_deref()
432 .zip(dependency.obj_name.as_deref())
433 .map(|(schema, name)| ObjectId::new(schema, name))
434 .ok_or_else(|| "view dependency is missing its object identity".to_string())?;
435 let referenced_id = dependency
436 .ref_schema
437 .as_deref()
438 .zip(dependency.ref_name.as_deref())
439 .map(|(schema, name)| ObjectId::new(schema, name))
440 .ok_or_else(|| {
441 format!(
442 "view dependency for '{}' is missing its referenced identity",
443 object_id
444 )
445 })?;
446 let omitted_schema = |schema: Option<&str>| {
447 self.metadata
448 .schemas
449 .as_ref()
450 .zip(schema)
451 .is_some_and(|(schemas, schema)| !schemas.iter().any(|known| known == schema))
452 };
453 let object_missing = !self.relations.contains_key(&object_id);
454 let referenced_missing = !self.relations.contains_key(&referenced_id);
455 if (object_missing && !omitted_schema(dependency.obj_schema.as_deref()))
456 || (referenced_missing && !omitted_schema(dependency.ref_schema.as_deref()))
457 {
458 return Err(format!(
459 "view dependency '{} -> {}' references a missing relation",
460 object_id, referenced_id
461 ));
462 }
463 }
464
465 Ok(())
466 }
467}
468
469#[cfg(test)]
470mod tests {
471 use super::*;
472
473 #[test]
474 fn every_legacy_cache_variant_is_rejected_generically() {
475 for (versioned, expected_version) in [
476 (DbCacheVersioned::V1, 1),
477 (DbCacheVersioned::V2, 2),
478 (DbCacheVersioned::V3, 3),
479 (DbCacheVersioned::V4, 4),
480 ] {
481 assert_eq!(versioned.format_version(), expected_version);
482 assert_eq!(
483 versioned.into_cache().unwrap_err(),
484 "This cache format is unsupported. Run `safe-migrate sync` to rebuild it."
485 );
486 }
487 let v5 = DbCacheVersioned::V5(Box::default());
488 assert_eq!(v5.format_version(), 5);
489 assert_eq!(
490 v5.into_cache().unwrap_err(),
491 "This cache format is unsupported. Run `safe-migrate sync` to rebuild it."
492 );
493 }
494
495 #[test]
496 fn current_cache_format_is_v6() {
497 assert_eq!(CACHE_FORMAT_VERSION, 6);
498 assert_eq!(DbCacheVersioned::V6(Box::default()).format_version(), 6);
499 assert_eq!(CACHE_V6_MAGIC, b"SMCACHE06");
500 }
501
502 #[test]
503 fn current_cache_rejects_mismatched_embedded_identity() {
504 let mut cache = DbCache::new();
505 cache.schemas.insert(
506 "app".to_string(),
507 SchemaState {
508 name: "other".to_string(),
509 owner: ObjectId::new("", "postgres"),
510 generation: 0,
511 },
512 );
513
514 let error = DbCacheVersioned::V6(Box::new(cache))
515 .into_cache()
516 .unwrap_err();
517 assert!(error.contains("schema cache key 'app'"));
518 }
519
520 #[test]
521 fn scoped_cache_accepts_a_dependency_to_an_omitted_schema() {
522 let view_id = ObjectId::new("app", "v");
523 let mut cache = DbCache::new();
524 cache.metadata.schemas = Some(vec!["app".to_string()]);
525 cache.insert_baseline(
526 view_id.clone(),
527 RelationState::new(
528 view_id.clone(),
529 ObjectId::new("", "postgres"),
530 0,
531 None,
532 crate::model::relation::RelationKind::View,
533 crate::model::relation::Persistence::Permanent,
534 0,
535 ),
536 );
537 cache.dependencies.push(DependencyCache {
538 classid: 0,
539 objid: 0,
540 objsubid: 0,
541 refclassid: 0,
542 refobjid: 0,
543 refobjsubid: 0,
544 deptype: "view".to_string(),
545 obj_schema: Some("app".to_string()),
546 obj_name: Some("v".to_string()),
547 ref_schema: Some("tenant".to_string()),
548 ref_name: Some("base".to_string()),
549 });
550
551 assert!(cache.validate_semantics().is_ok());
552 }
553
554 #[test]
555 fn current_cache_rejects_dangling_index_relationship() {
556 let mut cache = DbCache::new();
557 cache.indexes.push(IndexCache {
558 index_id: ObjectId::new("public", "items_idx"),
559 table_id: ObjectId::new("public", "items"),
560 });
561
562 let error = DbCacheVersioned::V6(Box::new(cache))
563 .into_cache()
564 .unwrap_err();
565 assert!(error.contains("references missing relation 'public.items'"));
566 }
567
568 #[test]
569 fn current_cache_rejects_cross_catalog_contradictions() {
570 let mut missing_search_schema = DbCache::new();
571 missing_search_schema.schemas.insert(
572 "app".to_string(),
573 SchemaState {
574 name: "app".to_string(),
575 owner: ObjectId::new("", "postgres"),
576 generation: 0,
577 },
578 );
579 assert!(
580 missing_search_schema
581 .validate_semantics()
582 .unwrap_err()
583 .contains("search path references missing schema 'public'")
584 );
585
586 let mut missing_sequence_owner = DbCache::new();
587 let sequence_id = ObjectId::new("public", "items_id_seq");
588 missing_sequence_owner.sequences.insert(
589 sequence_id.clone(),
590 SequenceState {
591 id: sequence_id,
592 owner: ObjectId::new("", "postgres"),
593 owned_by: Some((ObjectId::new("public", "items"), "id".to_string())),
594 kind: crate::model::sequence::SequenceKind::Owned,
595 generation: 0,
596 },
597 );
598 assert!(
599 missing_sequence_owner
600 .validate_semantics()
601 .unwrap_err()
602 .contains("ownership references missing relation 'public.items'")
603 );
604
605 let mut missing_membership_role = DbCache::new();
606 let role_id = ObjectId::new("", "member");
607 missing_membership_role.roles.insert(
608 role_id.clone(),
609 RoleState {
610 id: role_id,
611 can_login: true,
612 is_superuser: false,
613 member_of: vec![ObjectId::new("", "missing")],
614 can_set_role_to: Vec::new(),
615 granted_privileges: Vec::new(),
616 },
617 );
618 assert!(
619 missing_membership_role
620 .validate_semantics()
621 .unwrap_err()
622 .contains("membership references missing role")
623 );
624
625 let mut incomplete_view_dependency = DbCache::new();
626 incomplete_view_dependency
627 .dependencies
628 .push(DependencyCache {
629 classid: 0,
630 objid: 0,
631 objsubid: 0,
632 refclassid: 0,
633 refobjid: 0,
634 refobjsubid: 0,
635 deptype: "view".to_string(),
636 obj_schema: None,
637 obj_name: None,
638 ref_schema: None,
639 ref_name: None,
640 });
641 assert!(
642 incomplete_view_dependency
643 .validate_semantics()
644 .unwrap_err()
645 .contains("view dependency is missing its object identity")
646 );
647 }
648}