1use std::collections::{HashMap, HashSet};
28
29use serde::{Deserialize, Serialize};
30
31use crate::schema::{SchemaDescriptor, SearchBackend, TypeDescriptor};
32
33#[derive(Debug, Default, Clone, Serialize, Deserialize)]
37pub struct DbState {
38 #[serde(default)]
40 pub schemas: Vec<String>,
41 #[serde(default)]
42 pub tables: Vec<DbTable>,
43 #[serde(default)]
44 pub enums: Vec<DbEnum>,
45 #[serde(default)]
46 pub domains: Vec<DbDomain>,
47 #[serde(default)]
51 pub composites: Vec<DbComposite>,
52 #[serde(default)]
53 pub sequences: Vec<DbSequence>,
54 #[serde(default)]
55 pub views: Vec<DbView>,
56 #[serde(default)]
57 pub functions: Vec<DbFunction>,
58 #[serde(default)]
62 pub extensions: Vec<String>,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct DbTable {
67 pub schema: String,
68 pub name: String,
69 pub columns: Vec<DbColumn>,
70 pub foreign_keys: Vec<DbForeignKey>,
71 pub indexes: Vec<DbIndex>,
72 pub checks: Vec<DbCheck>,
73 #[serde(default)]
74 pub triggers: Vec<String>,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct DbColumn {
79 pub name: String,
80 pub pg_type: String,
81 pub nullable: bool,
82 pub is_generated: bool,
83 #[serde(default)]
84 pub column_default: Option<String>,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct DbForeignKey {
89 pub constraint_name: String,
90 pub local_column: String,
91 pub ref_schema: String,
92 pub ref_table: String,
93}
94
95fn capped_index_name(name: String) -> String {
99 const MAX: usize = 63;
100 if name.len() <= MAX {
101 return name;
102 }
103 let hash = crate::export::fnv(&[&name]);
104 let head: String = name.chars().take(MAX - 9).collect();
105 format!("{head}_{}", &hash[..8])
106}
107
108fn pointer_column(td: &TypeDescriptor, pointer: &str) -> String {
111 if td.links.iter().any(|l| l.name == pointer && !l.is_junction_backed()) {
112 format!("{pointer}_id")
113 } else {
114 pointer.to_string()
115 }
116}
117
118fn inherited_from_an_interface(schema: &SchemaDescriptor, td: &TypeDescriptor, pointer: &str) -> bool {
122 td.interfaces.iter().any(|iface| {
123 schema
124 .types
125 .iter()
126 .filter(|t| format!("{}::{}", t.module, t.name) == *iface)
127 .any(|t| {
128 t.properties.iter().any(|p| p.name == pointer)
129 || t.links.iter().any(|l| l.name == pointer)
130 || t.multilinks.iter().any(|ml| ml.name == pointer)
131 })
132 })
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct DbIndex {
137 pub name: String,
138 pub is_unique: bool,
139 pub method: String,
140 #[serde(default)]
145 pub columns: Vec<String>,
146 #[serde(default)]
150 pub predicate: Option<String>,
151 #[serde(default)]
155 pub key: Option<String>,
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct DbCheck {
160 pub constraint_name: String,
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct DbEnum {
165 pub schema: String,
166 pub name: String,
167 pub members: Vec<String>,
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct DbComposite {
176 pub schema: String,
179 pub name: String,
180 #[serde(default)]
181 pub attributes: Vec<DbCompositeAttr>,
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct DbCompositeAttr {
186 pub name: String,
187 pub pg_type: String,
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct DbDomain {
194 pub schema: String,
195 pub name: String,
196 #[serde(default)]
198 pub checks: Vec<String>,
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct DbSequence {
203 pub schema: String,
204 pub name: String,
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize)]
208pub struct DbView {
209 pub schema: String,
210 pub name: String,
211 pub body_hash: String,
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize)]
216pub struct DbFunction {
217 pub schema: String,
218 pub name: String,
219 pub body_hash: String,
221}
222
223pub fn schema_to_db_state(schema: &SchemaDescriptor) -> DbState {
232 use std::collections::BTreeSet;
233
234 let type_map: HashMap<String, (&str, &str)> = schema
235 .types
236 .iter()
237 .map(|t| {
238 (
239 format!("{}::{}", t.module, t.name),
240 (t.module.as_str(), t.table.as_str()),
241 )
242 })
243 .collect();
244
245 let mut schema_set: BTreeSet<String> = BTreeSet::new();
247 for t in &schema.types {
248 schema_set.insert(t.module.clone());
249 }
250 for e in &schema.enums {
251 schema_set.insert(e.module.clone());
252 }
253 for s in &schema.scalars {
254 schema_set.insert(s.module.clone());
255 }
256 for nt in &schema.named_tuples {
257 schema_set.insert(nt.module.clone());
258 }
259
260 let schemas: Vec<String> = schema_set.into_iter().collect();
261
262 let enums: Vec<DbEnum> = schema
264 .enums
265 .iter()
266 .map(|e| DbEnum {
267 schema: e.module.clone(),
268 name: e.name.clone(),
269 members: e.members.clone(),
270 })
271 .collect();
272
273 let composites: Vec<DbComposite> = target_tuple_types(schema)
277 .into_iter()
278 .map(|t| DbComposite {
279 schema: t.module,
280 name: t.name,
281 attributes: t
282 .attributes
283 .into_iter()
284 .map(|a| DbCompositeAttr {
285 name: a.name,
286 pg_type: a.pg_type,
287 })
288 .collect(),
289 })
290 .collect();
291
292 let domains: Vec<DbDomain> = schema
294 .scalars
295 .iter()
296 .map(|s| DbDomain {
297 schema: s.module.clone(),
298 name: s.name.clone(),
299 checks: crate::export::scalar_check_constraints(schema)
300 .into_iter()
301 .filter(|(module, name, _, _)| *module == s.module && *name == s.name)
302 .map(|(_, _, cname, _)| cname)
303 .collect(),
304 })
305 .collect();
306
307 let sequences: Vec<DbSequence> = schema
309 .scalars
310 .iter()
311 .filter(|s| s.is_sequence)
312 .map(|s| DbSequence {
313 schema: s.module.clone(),
314 name: format!("{}_seq", s.name),
315 })
316 .collect();
317
318 let expected_trigger_names = expected_triggers(schema, &type_map);
319 let mut expected_checks: HashMap<(String, String), Vec<String>> = HashMap::new();
320 for (module, table, cname, _) in crate::export::check_constraints(schema).unwrap_or_default() {
321 expected_checks.entry((module, table)).or_default().push(cname);
322 }
323 let mut tables: Vec<DbTable> = Vec::new();
324
325 for td in &schema.types {
326 if td.abstract_ || td.junction {
327 continue;
328 }
329
330 let mut columns: Vec<DbColumn> = Vec::new();
332 for p in &td.properties {
333 columns.push(DbColumn {
334 name: p.name.clone(),
335 pg_type: col_type_str(p, &td.module),
336 nullable: p.nullable,
337 is_generated: false,
338 column_default: resolve_default(p, schema),
339 });
340 }
341 for l in &td.links {
342 if l.is_junction_backed() {
346 continue;
347 }
348 columns.push(DbColumn {
349 name: format!("{}_id", l.name),
350 pg_type: "uuid".to_string(),
351 nullable: l.nullable,
352 is_generated: false,
353 column_default: resolve_link_default(l, schema),
354 });
355 }
356 for vi in &td.vector_indexes {
358 let col = vi.column_name();
359 if !columns.iter().any(|c| c.name == col) {
360 columns.push(DbColumn {
361 name: col,
362 pg_type: format!("vector({})", vi.dimensions),
363 nullable: true,
364 is_generated: false,
365 column_default: None,
366 });
367 }
368 }
369 for si in &td.search_indexes {
371 if si.backend != SearchBackend::Postgres {
372 continue;
373 }
374 let col = si.column_name();
375 if !columns.iter().any(|c| c.name == col) {
376 columns.push(DbColumn {
377 name: col,
378 pg_type: "tsvector".to_string(),
379 nullable: true,
380 is_generated: true,
381 column_default: None,
382 });
383 }
384 }
385
386 let mut foreign_keys: Vec<DbForeignKey> = Vec::new();
388 for l in &td.links {
389 if l.is_junction_backed() {
390 continue;
391 }
392 let cname = format!("{}_{}_fkey", td.table, l.name);
393 if let Some((tgt_schema, tgt_table)) = type_map.get(&l.target) {
394 foreign_keys.push(DbForeignKey {
395 constraint_name: cname,
396 local_column: format!("{}_id", l.name),
397 ref_schema: tgt_schema.to_string(),
398 ref_table: tgt_table.to_string(),
399 });
400 }
401 }
402
403 let mut indexes: Vec<DbIndex> = Vec::new();
405 for p in &td.properties {
406 if p.is_exclusive && !p.is_pk && !inherited_from_an_interface(schema, td, &p.name) {
411 indexes.push(DbIndex {
412 name: format!("{}_{}_key", td.table, p.name),
413 is_unique: true,
414 method: "btree".to_string(),
415 columns: vec![p.name.clone()],
416 predicate: None,
417 key: None,
418 });
419 }
420 }
421 for l in &td.links {
422 if l.is_exclusive && !l.is_junction_backed() && !inherited_from_an_interface(schema, td, &l.name) {
426 indexes.push(DbIndex {
427 name: format!("{}_{}_id_key", td.table, l.name),
428 is_unique: true,
429 method: "btree".to_string(),
430 columns: vec![format!("{}_id", l.name)],
431 predicate: None,
432 key: None,
433 });
434 }
435 }
436 for (i, constraint) in td.constraints.iter().enumerate() {
437 use crate::schema::TypeConstraint;
438 if let TypeConstraint::Exclusive {
439 pointers: fields,
440 unless,
441 } = constraint
442 {
443 let idx_name = format!("{}_{}_{}_key", td.table, fields.join("_"), i);
445 indexes.push(DbIndex {
446 name: idx_name,
447 is_unique: true,
448 method: "btree".to_string(),
449 columns: fields.iter().map(|f| pointer_column(td, f)).collect(),
450 predicate: unless.as_deref().and_then(|u| {
451 let qualified = format!("{}::{}", td.module, td.name);
452 crate::ir::compile_constraint_expr(u, &qualified, schema)
453 .ok()
454 .map(|c| format!(" WHERE NOT ({c})"))
455 }),
456 key: None,
457 });
458 }
459 }
460 for (i, idx) in td.indexes.iter().enumerate() {
462 let name = if idx.expression.is_some() {
463 format!("{}__expr{}_idx", td.table, i)
464 } else {
465 format!("{}__{}_idx", td.table, idx.pointers.join("_"))
466 };
467 let Ok((body, predicate)) = crate::export::index_body_and_predicate(
471 td,
472 &idx.pointers,
473 idx.expression.as_deref(),
474 idx.unless.as_deref(),
475 schema,
476 ) else {
477 continue;
478 };
479 indexes.push(DbIndex {
480 name,
481 is_unique: idx.unique,
482 method: "btree".to_string(),
483 columns: vec![],
484 key: Some(body),
485 predicate: (!predicate.is_empty()).then_some(predicate),
486 });
487 }
488 for vi in &td.vector_indexes {
490 let idx_name = match &vi.index_name {
491 None => format!("{}__vector__", td.table),
492 Some(n) => format!("{}__vector_{}__", td.table, n),
493 };
494 indexes.push(DbIndex {
495 name: idx_name,
496 is_unique: false,
497 method: "hnsw".to_string(),
498 columns: vec![],
499 predicate: None,
500 key: None,
501 });
502 }
503 for si in &td.search_indexes {
505 if si.backend != SearchBackend::Postgres {
506 continue;
507 }
508 let idx_name = match &si.index_name {
509 None => format!("{}__search__", td.table),
510 Some(n) => format!("{}__search_{}__", td.table, n),
511 };
512 indexes.push(DbIndex {
513 name: idx_name,
514 is_unique: false,
515 method: "gin".to_string(),
516 columns: vec![],
517 predicate: None,
518 key: None,
519 });
520 }
521
522 let checks: Vec<DbCheck> = expected_checks
526 .get(&(td.module.clone(), td.table.clone()))
527 .cloned()
528 .unwrap_or_default()
529 .into_iter()
530 .map(|constraint_name| DbCheck { constraint_name })
531 .collect();
532
533 let triggers: Vec<String> = expected_trigger_names
534 .get(&(td.module.clone(), td.table.clone()))
535 .cloned()
536 .unwrap_or_default()
537 .into_iter()
538 .collect();
539 tables.push(DbTable {
540 schema: td.module.clone(),
541 name: td.table.clone(),
542 columns,
543 foreign_keys,
544 indexes,
545 checks,
546 triggers,
547 });
548
549 for ml in &td.multilinks {
551 tables.push(build_junction_db_table(
552 schema,
553 &type_map,
554 td,
555 &ml.name,
556 &ml.target,
557 ml.through.as_deref(),
558 ml.is_exclusive,
559 &expected_trigger_names,
560 ));
561 }
562 for l in &td.links {
569 if !l.is_junction_backed() {
570 continue;
571 }
572 tables.push(build_junction_db_table(
573 schema,
574 &type_map,
575 td,
576 &l.name,
577 &l.target,
578 l.through.as_deref(),
579 l.is_exclusive,
580 &expected_trigger_names,
581 ));
582 }
583 }
584
585 let views: Vec<DbView> = crate::export::interface_view_ddl_with_names(schema)
590 .into_iter()
591 .chain(crate::export::interface_junction_view_ddl_with_names(schema))
592 .map(|(module, name, ddl)| DbView {
593 schema: module,
594 name,
595 body_hash: ddl_hash(&ddl),
596 })
597 .collect();
598
599 let functions: Vec<DbFunction> = crate::export::function_ddl_with_names(schema)
601 .unwrap_or_default()
602 .into_iter()
603 .map(|(module, name, ddl)| DbFunction {
604 schema: module,
605 name,
606 body_hash: ddl_hash(&ddl),
607 })
608 .collect();
609
610 let extensions: Vec<String> = required_extensions(schema).iter().map(|s| s.to_string()).collect();
611
612 let polymorphic_tables: HashSet<(&str, &str)> = {
614 let polymorphic = crate::export::polymorphic_types(schema);
615 schema
616 .types
617 .iter()
618 .filter(|t| polymorphic.contains(&format!("{}::{}", t.module, t.name)))
619 .map(|t| (t.module.as_str(), t.table.as_str()))
620 .collect()
621 };
622 for table in &mut tables {
623 table.foreign_keys.retain(|fk| {
624 fk.local_column == "source"
625 || !polymorphic_tables.contains(&(fk.ref_schema.as_str(), fk.ref_table.as_str()))
626 });
627 }
628
629 DbState {
630 schemas,
631 tables,
632 enums,
633 domains,
634 composites,
635 sequences,
636 views,
637 functions,
638 extensions,
639 }
640}
641
642fn target_tuple_types(schema: &SchemaDescriptor) -> Vec<crate::schema::tuple_type::TupleType> {
648 crate::schema::tuple_type::collect(schema).unwrap_or_default()
649}
650
651pub fn required_extensions(target: &SchemaDescriptor) -> Vec<&'static str> {
657 let mut out = Vec::new();
658 if target.types.iter().any(|t| !t.vector_indexes.is_empty()) {
659 out.push("vector");
660 }
661 if target.types.iter().any(|t| t.partition.is_some()) {
662 out.push("pg_partman");
663 }
664 out
665}
666
667pub fn missing_extension_ddl(target: &SchemaDescriptor, current: &DbState) -> Vec<String> {
674 required_extensions(target)
675 .into_iter()
676 .filter(|ext| !current.extensions.iter().any(|e| e == ext))
677 .map(|ext| format!("CREATE EXTENSION IF NOT EXISTS \"{ext}\";"))
678 .collect()
679}
680
681#[allow(clippy::too_many_arguments)]
688fn build_junction_db_table(
689 schema: &SchemaDescriptor,
690 type_map: &HashMap<String, (&str, &str)>,
691 td: &TypeDescriptor,
692 name: &str,
693 target: &str,
694 through: Option<&str>,
695 exclusive: bool,
696 expected_trigger_names: &HashMap<(String, String), HashSet<String>>,
697) -> DbTable {
698 let jt_name = format!("{}.{}", td.table, name);
699 let mut jt_columns = vec![
700 DbColumn {
701 name: "source".to_string(),
702 pg_type: "uuid".to_string(),
703 nullable: false,
704 is_generated: false,
705 column_default: None,
706 },
707 DbColumn {
708 name: "target".to_string(),
709 pg_type: "uuid".to_string(),
710 nullable: false,
711 is_generated: false,
712 column_default: None,
713 },
714 ];
715
716 if let Some(through_qname) = through
718 && let Some(through_td) = schema
719 .types
720 .iter()
721 .find(|t| format!("{}::{}", t.module, t.name) == *through_qname && t.junction)
722 {
723 for p in &through_td.properties {
724 if p.name == "id" {
725 continue;
726 }
727 let pg_type = col_type_str(p, &through_td.module);
728 jt_columns.push(DbColumn {
729 name: p.name.clone(),
730 pg_type,
731 nullable: p.nullable,
732 is_generated: false,
733 column_default: p.default_sql.clone(),
734 });
735 }
736 }
737
738 let mut jt_fks = Vec::new();
739 let src_fk_name = format!("{}_{}_source_fkey", td.table, name);
740 jt_fks.push(DbForeignKey {
741 constraint_name: src_fk_name,
742 local_column: "source".to_string(),
743 ref_schema: td.module.clone(),
744 ref_table: td.table.clone(),
745 });
746 if let Some((tgt_schema, tgt_table)) = type_map.get(target) {
747 let tgt_fk_name = format!("{}_{}_target_fkey", td.table, name);
748 jt_fks.push(DbForeignKey {
749 constraint_name: tgt_fk_name,
750 local_column: "target".to_string(),
751 ref_schema: tgt_schema.to_string(),
752 ref_table: tgt_table.to_string(),
753 });
754 }
755
756 let mut indexes = Vec::new();
759 if exclusive && !inherited_from_an_interface(schema, td, name) {
760 indexes.push(DbIndex {
761 name: format!("{jt_name}_target_key"),
762 is_unique: true,
763 method: "btree".to_string(),
764 columns: vec!["target".to_string()],
765 predicate: None,
766 key: None,
767 });
768 }
769
770 let triggers: Vec<String> = expected_trigger_names
771 .get(&(td.module.clone(), jt_name.clone()))
772 .cloned()
773 .unwrap_or_default()
774 .into_iter()
775 .collect();
776
777 DbTable {
778 schema: td.module.clone(),
779 name: jt_name,
780 columns: jt_columns,
781 foreign_keys: jt_fks,
782 indexes,
783 checks: vec![],
784 triggers,
785 }
786}
787
788fn ddl_hash(ddl: &str) -> String {
789 use sha2::{Digest, Sha256};
790 let digest = Sha256::digest(ddl.as_bytes());
791 hex::encode(&digest[..8])
792}
793
794pub fn db_state_to_json(state: &DbState) -> String {
796 serde_json::to_string(state).expect("DbState serialization is infallible")
797}
798
799pub fn db_state_from_json(json: &str) -> Result<DbState, String> {
801 serde_json::from_str(json).map_err(|e| e.to_string())
802}
803
804impl DbState {
805 pub fn add_trigger(&mut self, module: &str, table: &str, trigger_name: &str) {
807 if let Some(t) = self.tables.iter_mut().find(|t| t.schema == module && t.name == table) {
808 t.triggers.push(trigger_name.to_string());
809 }
810 }
811}
812
813fn junction_table_belongs_to_an_interface(schema: &SchemaDescriptor, td: &TypeDescriptor) -> bool {
839 let Some((owner_table, _)) = td.table.rsplit_once('.') else {
840 return false;
841 };
842 schema
843 .types
844 .iter()
845 .any(|t| t.abstract_ && t.module == td.module && t.table == owner_table)
846}
847
848pub fn expected_triggers(
849 schema: &SchemaDescriptor,
850 type_map: &HashMap<String, (&str, &str)>,
851) -> HashMap<(String, String), HashSet<String>> {
852 let mut expected: HashMap<(String, String), HashSet<String>> = HashMap::new();
853
854 for info in crate::export::interface_exclusive_trigger_infos(schema) {
855 expected
856 .entry((info.impl_module.clone(), info.impl_table.clone()))
857 .or_default()
858 .extend([info.ins_trigger_name, info.upd_trigger_name]);
859 }
860 for info in crate::export::deletion_policy_trigger_infos(schema, type_map) {
861 expected
862 .entry((info.table_module.clone(), info.table_name.clone()))
863 .or_default()
864 .insert(info.trigger_name);
865 }
866 for info in crate::export::signal_trigger_infos(schema) {
867 expected
868 .entry((info.table_module.clone(), info.table_name.clone()))
869 .or_default()
870 .insert(info.trigger_name);
871 }
872 for (module, table, name) in crate::export::user_trigger_names(schema) {
873 expected.entry((module, table)).or_default().insert(name);
874 }
875
876 let mut cache_trigger_tables: HashSet<(String, String)> = HashSet::new();
881 for td in &schema.types {
882 if td.abstract_ || junction_table_belongs_to_an_interface(schema, td) {
883 continue;
884 }
885 cache_trigger_tables.insert((td.module.clone(), td.table.clone()));
886 if !td.junction {
887 for ml in &td.multilinks {
888 cache_trigger_tables.insert((td.module.clone(), format!("{}.{}", td.table, ml.name)));
889 }
890 for l in &td.links {
891 if !l.is_junction_backed() {
892 continue;
893 }
894 cache_trigger_tables.insert((td.module.clone(), format!("{}.{}", td.table, l.name)));
895 }
896 }
897 }
898 for key in cache_trigger_tables {
899 expected
900 .entry(key)
901 .or_default()
902 .insert("pylon_cache_invalidate".to_string());
903 }
904
905 expected
906}
907
908#[derive(Debug)]
913pub struct TypeRenameCandidate {
914 pub old_module: String,
915 pub old_table: String,
916 pub new_module: String,
917 pub new_table: String,
918 pub new_type_name: String,
920 pub confidence: f64,
922}
923
924#[derive(Debug)]
927pub struct ColRenameCandidate {
928 pub module: String,
929 pub table: String,
930 pub old_col: String,
931 pub new_col: String,
932 pub pg_type: String,
933}
934
935#[derive(Debug)]
939pub struct FillRequired {
940 pub module: String,
942 pub table: String,
943 pub column: String,
944 pub pg_type: String,
945 pub type_name: String,
947 pub is_new_column: bool,
950 pub default_sql: Option<String>,
953}
954
955#[derive(Debug, Clone)]
959pub struct DiffOp {
960 pub sql: String,
961 pub non_transactional: bool,
965}
966
967#[derive(Debug, Clone, Copy, PartialEq, Eq)]
974pub enum Verb {
975 Create,
976 Alter,
977 Drop,
978 Rename,
979}
980
981impl Verb {
982 pub fn as_str(&self) -> &'static str {
983 match self {
984 Verb::Create => "create",
985 Verb::Alter => "alter",
986 Verb::Drop => "drop",
987 Verb::Rename => "rename",
988 }
989 }
990}
991
992#[derive(Debug, Clone, PartialEq, Eq, Hash)]
996pub enum OpKey {
997 Module(String),
998 Scalar(String, String),
1001 TupleType(String, String),
1004 TupleTypeRebuild,
1013 Table(String, String),
1019 Function(String, String),
1020 View(String, String),
1021 ForeignKey(String, String),
1032}
1033
1034#[derive(Debug)]
1035pub struct MigrationStep {
1036 pub prompt: String,
1039 pub verb: Verb,
1040 pub object_desc: String,
1042 pub ddl: Vec<DiffOp>,
1046 pub op_key: OpKey,
1047 pub required_input: Vec<RequiredInput>,
1051}
1052
1053impl MigrationStep {
1054 pub fn resolved_ddl(&self, overrides: &HashMap<String, String>) -> Vec<DiffOp> {
1058 self.ddl
1059 .iter()
1060 .map(|op| {
1061 let mut sql = op.sql.clone();
1062 for input in &self.required_input {
1063 let value = overrides.get(&input.placeholder).unwrap_or(&input.default_expr);
1064 sql = sql.replace(&format!("\\({})", input.placeholder), value);
1065 }
1066 DiffOp {
1067 sql,
1068 non_transactional: op.non_transactional,
1069 }
1070 })
1071 .collect()
1072 }
1073}
1074
1075#[derive(Debug, Clone)]
1081pub struct RequiredInput {
1082 pub placeholder: String,
1084 pub prompt: String,
1086 pub default_expr: String,
1090 pub type_name: String,
1092}
1093
1094fn verbosename_module(name: &str) -> String {
1095 format!("module '{name}'")
1096}
1097
1098fn is_generated_check_name(name: &str) -> bool {
1102 let Some(rest) = name.strip_suffix("_check") else {
1103 return false;
1104 };
1105 match rest.rsplit_once('_') {
1106 Some((prefix, hash)) => !prefix.is_empty() && hash.len() == 8 && hash.chars().all(|c| c.is_ascii_hexdigit()),
1107 None => false,
1108 }
1109}
1110
1111fn verbosename_scalar(module: &str, name: &str) -> String {
1115 format!("scalar type '{module}::{name}'")
1116}
1117
1118fn verbosename_tuple_type(t: &crate::schema::tuple_type::TupleType) -> String {
1122 match &t.nominal {
1123 Some(qname) => format!("tuple type '{qname}'"),
1124 None => format!("tuple type '{}'", t.signature),
1125 }
1126}
1127
1128fn verbosename_type(module: &str, name: &str) -> String {
1129 format!("object type '{module}::{name}'")
1130}
1131
1132fn verbosename_interface(module: &str, name: &str) -> String {
1133 format!("interface type '{module}::{name}'")
1134}
1135
1136fn verbosename_function(module: &str, name: &str) -> String {
1137 format!("function '{module}::{name}'")
1138}
1139
1140#[derive(Default)]
1145struct StepBuilder {
1146 order: Vec<OpKey>,
1147 drafts: HashMap<OpKey, (Verb, String, Vec<DiffOp>, Vec<RequiredInput>)>,
1148}
1149
1150impl StepBuilder {
1151 fn new() -> Self {
1152 Self::default()
1153 }
1154
1155 fn push(&mut self, key: OpKey, verb: Verb, object_desc: impl Into<String>, op: DiffOp) {
1156 self.extend(key, verb, object_desc, vec![op]);
1157 }
1158
1159 fn extend(&mut self, key: OpKey, verb: Verb, object_desc: impl Into<String>, ops: Vec<DiffOp>) {
1160 self.extend_with_input(key, verb, object_desc, ops, vec![]);
1161 }
1162
1163 fn extend_with_input(
1164 &mut self,
1165 key: OpKey,
1166 verb: Verb,
1167 object_desc: impl Into<String>,
1168 ops: Vec<DiffOp>,
1169 inputs: Vec<RequiredInput>,
1170 ) {
1171 if ops.is_empty() && inputs.is_empty() {
1172 return;
1173 }
1174 use std::collections::hash_map::Entry;
1175 match self.drafts.entry(key.clone()) {
1176 Entry::Occupied(mut e) => {
1177 e.get_mut().2.extend(ops);
1178 e.get_mut().3.extend(inputs);
1179 }
1180 Entry::Vacant(e) => {
1181 e.insert((verb, object_desc.into(), ops, inputs));
1182 self.order.push(key);
1183 }
1184 }
1185 }
1186
1187 fn position(&self, key: &OpKey) -> Option<usize> {
1189 self.order.iter().position(|existing| existing == key)
1190 }
1191
1192 fn finish(self) -> Vec<MigrationStep> {
1193 let Self { order, mut drafts } = self;
1194 order
1195 .into_iter()
1196 .map(|key| {
1197 let (verb, object_desc, ddl, required_input) = drafts.remove(&key).unwrap();
1198 let prompt = format!("did you {} {}?", verb.as_str(), object_desc);
1199 MigrationStep {
1200 prompt,
1201 verb,
1202 object_desc,
1203 ddl,
1204 op_key: key,
1205 required_input,
1206 }
1207 })
1208 .collect()
1209 }
1210}
1211
1212#[derive(Debug, Default, Clone)]
1217pub struct Guidance {
1218 pub banned_type_renames: HashSet<(String, String, String, String)>,
1221 pub banned_col_renames: HashSet<(String, String, String, String)>,
1224}
1225
1226pub fn diff_schema(target: &SchemaDescriptor, current: &DbState) -> Result<Vec<String>, String> {
1232 Ok(flatten_ops(diff_inner(target, current, false, &HashMap::new())?)
1233 .into_iter()
1234 .map(|op| op.sql)
1235 .collect())
1236}
1237
1238pub fn diff_schema_ops(target: &SchemaDescriptor, current: &DbState) -> Result<Vec<DiffOp>, String> {
1242 Ok(flatten_ops(diff_inner(target, current, true, &HashMap::new())?))
1243}
1244
1245pub fn schema_content_changed(target: &SchemaDescriptor, previous: Option<&SchemaDescriptor>) -> bool {
1281 let previous = previous.cloned().unwrap_or_default();
1282 serde_json::to_value(target).ok() != serde_json::to_value(&previous).ok()
1287}
1288
1289pub fn diff_schema_steps(
1295 target: &SchemaDescriptor,
1296 current: &DbState,
1297 fill_index: &HashMap<(String, String), HashSet<String>>,
1298) -> Result<Vec<MigrationStep>, String> {
1299 diff_inner(target, current, true, fill_index)
1300}
1301
1302fn flatten_ops(steps: Vec<MigrationStep>) -> Vec<DiffOp> {
1309 let no_overrides = HashMap::new();
1310 steps.iter().flat_map(|s| s.resolved_ddl(&no_overrides)).collect()
1311}
1312
1313pub fn diff_states(before: &DbState, after: &DbState) -> Vec<DiffOp> {
1318 diff_states_inner(before, after)
1319}
1320
1321pub fn detect_type_renames(
1327 target: &SchemaDescriptor,
1328 current: &DbState,
1329 guidance: &Guidance,
1330) -> Vec<TypeRenameCandidate> {
1331 let target_keys: HashSet<(&str, &str)> = target
1332 .types
1333 .iter()
1334 .filter(|t| !t.abstract_ && !t.junction)
1335 .map(|t| (t.module.as_str(), t.table.as_str()))
1336 .collect();
1337 let current_keys: HashSet<(&str, &str)> = current
1338 .tables
1339 .iter()
1340 .map(|t| (t.schema.as_str(), t.name.as_str()))
1341 .collect();
1342
1343 let dropped: Vec<&DbTable> = current
1344 .tables
1345 .iter()
1346 .filter(|t| !target_keys.contains(&(t.schema.as_str(), t.name.as_str())))
1347 .collect();
1348 let created: Vec<&TypeDescriptor> = target
1349 .types
1350 .iter()
1351 .filter(|t| !t.abstract_ && !t.junction)
1352 .filter(|t| !current_keys.contains(&(t.module.as_str(), t.table.as_str())))
1353 .collect();
1354
1355 if dropped.is_empty() || created.is_empty() {
1356 return vec![];
1357 }
1358
1359 let mut candidates: Vec<TypeRenameCandidate> = Vec::new();
1360 for dropped_t in &dropped {
1361 let old_cols: HashSet<&str> = dropped_t
1362 .columns
1363 .iter()
1364 .map(|c| c.name.as_str())
1365 .filter(|n| !n.starts_with("__"))
1366 .collect();
1367 for new_type in &created {
1368 let new_cols: HashSet<&str> = new_type.properties.iter().map(|p| p.name.as_str()).collect();
1369 let intersection = old_cols.intersection(&new_cols).count();
1370 let union_size = old_cols.union(&new_cols).count();
1371 if union_size == 0 {
1372 continue;
1373 }
1374 let confidence = intersection as f64 / union_size as f64;
1375 let banned = guidance.banned_type_renames.contains(&(
1376 dropped_t.schema.clone(),
1377 dropped_t.name.clone(),
1378 new_type.module.clone(),
1379 new_type.table.clone(),
1380 ));
1381 if confidence >= 0.4 && !banned {
1382 candidates.push(TypeRenameCandidate {
1383 old_module: dropped_t.schema.clone(),
1384 old_table: dropped_t.name.clone(),
1385 new_module: new_type.module.clone(),
1386 new_table: new_type.table.clone(),
1387 new_type_name: new_type.name.clone(),
1388 confidence,
1389 });
1390 }
1391 }
1392 }
1393 candidates.sort_by(|a, b| {
1394 b.confidence
1395 .partial_cmp(&a.confidence)
1396 .unwrap_or(std::cmp::Ordering::Equal)
1397 });
1398 candidates
1399}
1400
1401pub fn detect_col_renames(
1406 target: &SchemaDescriptor,
1407 current: &DbState,
1408 guidance: &Guidance,
1409) -> Vec<ColRenameCandidate> {
1410 let cur_tables: HashMap<(&str, &str), &DbTable> = current
1411 .tables
1412 .iter()
1413 .map(|t| ((t.schema.as_str(), t.name.as_str()), t))
1414 .collect();
1415
1416 let mut candidates: Vec<ColRenameCandidate> = Vec::new();
1417 for td in &target.types {
1418 if td.abstract_ || td.junction {
1419 continue;
1420 }
1421 let Some(cur) = cur_tables.get(&(td.module.as_str(), td.table.as_str())) else {
1422 continue;
1423 };
1424
1425 let target_cols: Vec<(String, String)> = td
1427 .properties
1428 .iter()
1429 .map(|p| (p.name.clone(), col_type_str(p, &td.module)))
1430 .chain(
1431 td.links
1432 .iter()
1433 .filter(|l| !l.is_junction_backed())
1434 .map(|l| (format!("{}_id", l.name), "uuid".to_string())),
1435 )
1436 .collect();
1437
1438 let cur_cols: Vec<(&str, &str)> = cur
1440 .columns
1441 .iter()
1442 .filter(|c| !c.name.starts_with("__"))
1443 .map(|c| (c.name.as_str(), c.pg_type.as_str()))
1444 .collect();
1445
1446 let dropped: Vec<(&str, &str)> = cur_cols
1448 .iter()
1449 .copied()
1450 .filter(|(name, _)| !target_cols.iter().any(|(t, _)| t.as_str() == *name))
1451 .collect();
1452 let added: Vec<(&str, &str)> = target_cols
1454 .iter()
1455 .filter(|(name, _)| !cur_cols.iter().any(|&(c, _)| c == name.as_str()))
1456 .map(|(n, t)| (n.as_str(), t.as_str()))
1457 .collect();
1458
1459 if dropped.is_empty() || added.is_empty() {
1460 continue;
1461 }
1462
1463 let mut dropped_by_type: HashMap<&str, Vec<&str>> = HashMap::new();
1466 for (name, pg_type) in &dropped {
1467 dropped_by_type.entry(pg_type).or_default().push(name);
1468 }
1469 let mut added_by_type: HashMap<&str, Vec<&str>> = HashMap::new();
1470 for (name, pg_type) in &added {
1471 added_by_type.entry(pg_type).or_default().push(name);
1472 }
1473
1474 for (pg_type, dropped_names) in &dropped_by_type {
1475 if let Some(added_names) = added_by_type.get(pg_type)
1476 && dropped_names.len() == 1
1477 && added_names.len() == 1
1478 {
1479 let banned = guidance.banned_col_renames.contains(&(
1480 td.module.clone(),
1481 td.table.clone(),
1482 dropped_names[0].to_string(),
1483 added_names[0].to_string(),
1484 ));
1485 if !banned {
1486 candidates.push(ColRenameCandidate {
1487 module: td.module.clone(),
1488 table: td.table.clone(),
1489 old_col: dropped_names[0].to_string(),
1490 new_col: added_names[0].to_string(),
1491 pg_type: pg_type.to_string(),
1492 });
1493 }
1494 }
1495 }
1496 }
1497 candidates
1498}
1499
1500pub fn detect_fill_required(target: &SchemaDescriptor, current: &DbState) -> Vec<FillRequired> {
1511 let cur_tables: HashMap<(&str, &str), &DbTable> = current
1512 .tables
1513 .iter()
1514 .map(|t| ((t.schema.as_str(), t.name.as_str()), t))
1515 .collect();
1516
1517 let mut result: Vec<FillRequired> = Vec::new();
1518
1519 for td in &target.types {
1520 if td.abstract_ || td.junction {
1521 continue;
1522 }
1523 let Some(cur) = cur_tables.get(&(td.module.as_str(), td.table.as_str())) else {
1524 continue;
1525 };
1526
1527 let cur_col_map: HashMap<&str, &DbColumn> = cur.columns.iter().map(|c| (c.name.as_str(), c)).collect();
1528
1529 for p in &td.properties {
1530 if p.nullable || p.is_pk {
1531 continue;
1532 }
1533 match cur_col_map.get(p.name.as_str()) {
1534 None => {
1535 if p.default_sql.is_none() {
1537 result.push(FillRequired {
1538 module: td.module.clone(),
1539 table: td.table.clone(),
1540 column: p.name.clone(),
1541 pg_type: col_type_str(p, &td.module),
1542 type_name: td.name.clone(),
1543 is_new_column: true,
1544 default_sql: None,
1545 });
1546 }
1547 }
1548 Some(cur_col) if cur_col.nullable => {
1549 result.push(FillRequired {
1551 module: td.module.clone(),
1552 table: td.table.clone(),
1553 column: p.name.clone(),
1554 pg_type: col_type_str(p, &td.module),
1555 type_name: td.name.clone(),
1556 is_new_column: false,
1557 default_sql: p.default_sql.clone(),
1558 });
1559 }
1560 _ => {}
1561 }
1562 }
1563
1564 for l in &td.links {
1565 if l.nullable || l.is_junction_backed() {
1566 continue;
1567 }
1568 let col = format!("{}_id", l.name);
1569 match cur_col_map.get(col.as_str()) {
1570 None => {
1571 result.push(FillRequired {
1572 module: td.module.clone(),
1573 table: td.table.clone(),
1574 column: col,
1575 pg_type: "uuid".to_string(),
1576 type_name: td.name.clone(),
1577 is_new_column: true,
1578 default_sql: None,
1579 });
1580 }
1581 Some(cur_col) if cur_col.nullable => {
1582 result.push(FillRequired {
1583 module: td.module.clone(),
1584 table: td.table.clone(),
1585 column: col,
1586 pg_type: "uuid".to_string(),
1587 type_name: td.name.clone(),
1588 is_new_column: false,
1589 default_sql: None,
1590 });
1591 }
1592 _ => {}
1593 }
1594 }
1595 }
1596 result
1597}
1598
1599fn apply_renames(
1610 state: &mut DbState,
1611 type_renames: &[(String, String, String, String)],
1612 col_renames: &[(String, String, String, String)],
1613) {
1614 for (old_mod, old_table, new_mod, new_table) in type_renames {
1615 if let Some(t) = state
1616 .tables
1617 .iter_mut()
1618 .find(|t| &t.schema == old_mod && &t.name == old_table)
1619 {
1620 t.schema = new_mod.clone();
1621 t.name = new_table.clone();
1622 }
1623 }
1624 for (module, table, old_col, new_col) in col_renames {
1625 if let Some(t) = state
1626 .tables
1627 .iter_mut()
1628 .find(|t| &t.schema == module && &t.name == table)
1629 && let Some(col) = t.columns.iter_mut().find(|c| &c.name == old_col)
1630 {
1631 col.name = new_col.clone();
1632 }
1633 }
1634}
1635
1636pub fn diff_schema_ops_with_renames_and_fills(
1650 target: &SchemaDescriptor,
1651 current: &DbState,
1652 type_renames: &[(String, String, String, String)],
1653 col_renames: &[(String, String, String, String)],
1654 fills: &[(String, String, String, String)],
1655) -> Result<Vec<DiffOp>, String> {
1656 let mut ops: Vec<DiffOp> = Vec::new();
1657 let mut modified = current.clone();
1658
1659 for (old_mod, old_table, new_mod, new_table) in type_renames {
1661 if old_mod == new_mod {
1662 push_tx(
1663 &mut ops,
1664 format!("ALTER TABLE {} RENAME TO {};", qn(old_mod, old_table), qi(new_table)),
1665 );
1666 } else {
1667 push_tx(
1668 &mut ops,
1669 format!("ALTER TABLE {} SET SCHEMA {};", qn(old_mod, old_table), qi(new_mod)),
1670 );
1671 push_tx(
1672 &mut ops,
1673 format!("ALTER TABLE {} RENAME TO {};", qn(new_mod, old_table), qi(new_table)),
1674 );
1675 }
1676 }
1677 for (module, table, old_col, new_col) in col_renames {
1678 push_tx(
1679 &mut ops,
1680 format!(
1681 "ALTER TABLE {} RENAME COLUMN {} TO {};",
1682 qn(module, table),
1683 qi(old_col),
1684 qi(new_col)
1685 ),
1686 );
1687 }
1688
1689 apply_renames(&mut modified, type_renames, col_renames);
1691
1692 let mut fill_index: HashMap<(String, String), HashSet<String>> = HashMap::new();
1694 for (module, table, col, _) in fills {
1695 fill_index
1696 .entry((module.clone(), table.clone()))
1697 .or_default()
1698 .insert(col.clone());
1699 }
1700
1701 let mut diff_ops = flatten_ops(diff_inner(target, &modified, true, &fill_index)?);
1703 ops.append(&mut diff_ops);
1704
1705 for (module, table, col, fill_expr) in fills {
1707 push_tx(
1708 &mut ops,
1709 format!(
1710 "UPDATE {} SET {} = {} WHERE {} IS NULL;",
1711 qn(module, table),
1712 qi(col),
1713 fill_expr,
1714 qi(col)
1715 ),
1716 );
1717 push_tx(
1718 &mut ops,
1719 format!(
1720 "ALTER TABLE {} ALTER COLUMN {} SET NOT NULL;",
1721 qn(module, table),
1722 qi(col)
1723 ),
1724 );
1725 }
1726
1727 Ok(ops)
1728}
1729
1730pub fn diff_schema_steps_with_renames_and_fills(
1739 target: &SchemaDescriptor,
1740 current: &DbState,
1741 type_renames: &[(String, String, String, String)],
1742 col_renames: &[(String, String, String, String)],
1743 fills: &[(String, String, String, String)],
1744) -> Result<Vec<MigrationStep>, String> {
1745 let mut modified = current.clone();
1746 apply_renames(&mut modified, type_renames, col_renames);
1747
1748 let mut fill_index: HashMap<(String, String), HashSet<String>> = HashMap::new();
1749 for (module, table, col, _) in fills {
1750 fill_index
1751 .entry((module.clone(), table.clone()))
1752 .or_default()
1753 .insert(col.clone());
1754 }
1755
1756 let mut steps = diff_inner(target, &modified, true, &fill_index)?;
1757
1758 for (module, table, col, fill_expr) in fills {
1759 let fill_ops = vec![
1760 DiffOp {
1761 sql: format!(
1762 "UPDATE {} SET {} = {} WHERE {} IS NULL;",
1763 qn(module, table),
1764 qi(col),
1765 fill_expr,
1766 qi(col)
1767 ),
1768 non_transactional: false,
1769 },
1770 DiffOp {
1771 sql: format!(
1772 "ALTER TABLE {} ALTER COLUMN {} SET NOT NULL;",
1773 qn(module, table),
1774 qi(col)
1775 ),
1776 non_transactional: false,
1777 },
1778 ];
1779 match steps
1780 .iter_mut()
1781 .find(|s| matches!(&s.op_key, OpKey::Table(m, t) if m == module && t == table))
1782 {
1783 Some(step) => step.ddl.extend(fill_ops),
1784 None => steps.push(MigrationStep {
1785 prompt: format!("did you {} {}?", Verb::Alter.as_str(), verbosename_type(module, table)),
1786 verb: Verb::Alter,
1787 object_desc: verbosename_type(module, table),
1788 ddl: fill_ops,
1789 required_input: vec![],
1790 op_key: OpKey::Table(module.clone(), table.clone()),
1791 }),
1792 }
1793 }
1794
1795 Ok(steps)
1796}
1797
1798fn qi(s: &str) -> String {
1801 format!("\"{}\"", s.replace('"', "\"\""))
1802}
1803
1804fn pg_schema(module: &str) -> String {
1805 if module == "default" {
1806 "\"public\"".into()
1807 } else {
1808 qi(module)
1809 }
1810}
1811
1812const NEW_TYPE_SUFFIX: &str = "__new";
1816
1817fn new_type_ref(t: &crate::schema::tuple_type::TupleType) -> String {
1818 crate::schema::tuple_type::type_ref(&t.module, &format!("{}{}", t.name, NEW_TYPE_SUFFIX))
1819}
1820
1821fn create_composite_sql(
1827 t: &crate::schema::tuple_type::TupleType,
1828 as_name: &str,
1829 rebuild: &HashSet<String>,
1830 all: &[(String, &crate::schema::tuple_type::TupleType)],
1831) -> String {
1832 let attributes: Vec<String> = t
1833 .attributes
1834 .iter()
1835 .map(|a| {
1836 let base = attribute_base_type(a);
1837 let pg_type = match rebuild.contains(base) {
1838 true => {
1839 let replacement = all
1840 .iter()
1841 .find(|(tref, _)| tref == base)
1842 .map(|(_, held)| new_type_ref(held))
1843 .unwrap_or_else(|| base.to_string());
1844 match a.pg_type.ends_with("[]") {
1845 true => format!("{replacement}[]"),
1846 false => replacement,
1847 }
1848 }
1849 false => a.pg_type.clone(),
1850 };
1851 format!("{} {}", qi(&a.name), pg_type)
1852 })
1853 .collect();
1854 format!(
1855 "DO $$ BEGIN CREATE TYPE {} AS ({}); EXCEPTION WHEN duplicate_object THEN NULL; END $$;",
1856 as_name,
1857 attributes.join(", ")
1858 )
1859}
1860
1861fn create_db_composite_sql(c: &DbComposite, as_name: &str) -> String {
1864 let attributes: Vec<String> = c
1865 .attributes
1866 .iter()
1867 .map(|a| format!("{} {}", qi(&a.name), a.pg_type))
1868 .collect();
1869 format!(
1870 "DO $$ BEGIN CREATE TYPE {} AS ({}); EXCEPTION WHEN duplicate_object THEN NULL; END $$;",
1871 as_name,
1872 attributes.join(", ")
1873 )
1874}
1875
1876fn new_db_type_ref(c: &DbComposite) -> String {
1877 qn(&c.schema, &format!("{}{}", c.name, NEW_TYPE_SUFFIX))
1878}
1879
1880fn db_composite_changed(after: &DbComposite, before: &DbComposite) -> bool {
1881 if after.attributes.len() != before.attributes.len() {
1882 return true;
1883 }
1884 after
1885 .attributes
1886 .iter()
1887 .zip(&before.attributes)
1888 .any(|(a, b)| a.name != b.name || pg_type_changed(&a.pg_type, &b.pg_type))
1889}
1890
1891fn db_columns_of_composite(state: &DbState, c: &DbComposite) -> Vec<(String, String, String, bool)> {
1896 let mut out = Vec::new();
1897 for t in &state.tables {
1898 for col in &t.columns {
1899 let is_array = col.pg_type.ends_with("[]");
1900 let base = col.pg_type.strip_suffix("[]").unwrap_or(&col.pg_type);
1901 if bare_type_name(base) == c.name {
1902 out.push((t.schema.clone(), t.name.clone(), col.name.clone(), is_array));
1903 }
1904 }
1905 }
1906 out
1907}
1908
1909fn attribute_base_type(a: &crate::schema::tuple_type::TupleAttribute) -> &str {
1912 a.pg_type.strip_suffix("[]").unwrap_or(&a.pg_type)
1913}
1914
1915fn composite_changed(t: &crate::schema::tuple_type::TupleType, existing: &DbComposite) -> bool {
1919 if t.attributes.len() != existing.attributes.len() {
1920 return true;
1921 }
1922 t.attributes
1923 .iter()
1924 .zip(&existing.attributes)
1925 .any(|(want, have)| want.name != have.name || pg_type_changed(&want.pg_type, &have.pg_type))
1926}
1927
1928fn tuple_columns(target: &SchemaDescriptor) -> Vec<(String, String, String, String)> {
1931 let mut out = Vec::new();
1932 for td in &target.types {
1933 if td.abstract_ {
1934 continue;
1935 }
1936 for p in &td.properties {
1937 if let Some(col_type) = crate::schema::tuple_type::property_column_type(p, &td.module) {
1938 out.push((td.module.clone(), td.table.clone(), p.name.clone(), col_type));
1939 }
1940 }
1941 }
1942 out
1943}
1944
1945fn tuple_conversion_expr(col: &str, target_type: &str) -> String {
1954 match target_type.strip_suffix("[]") {
1955 Some(element) => format!("_pylon.populate_tuples(NULL::{element}, to_jsonb({}))", qi(col)),
1956 None => format!("_pylon.populate_tuple(NULL::{target_type}, to_jsonb({}))", qi(col)),
1957 }
1958}
1959
1960fn enum_target(schema: &SchemaDescriptor, pg_type: &str) -> bool {
1964 let bare = pg_type.strip_suffix("[]").unwrap_or(pg_type);
1965 schema
1966 .enums
1967 .iter()
1968 .any(|e| bare == qn(&e.module, &e.name) || bare == qi(&e.name))
1969}
1970
1971fn qn(schema: &str, name: &str) -> String {
1972 format!("{}.{}", pg_schema(schema), qi(name))
1973}
1974
1975fn topo_sort_types(types: &[TypeDescriptor], polymorphic: &HashSet<String>) -> Vec<usize> {
1991 let idx_of: HashMap<String, usize> = types
1992 .iter()
1993 .enumerate()
1994 .map(|(i, t)| (format!("{}::{}", t.module, t.name), i))
1995 .collect();
1996
1997 let mut colour = vec![0u8; types.len()];
1999 let mut order: Vec<usize> = Vec::with_capacity(types.len());
2000
2001 fn visit(
2002 i: usize,
2003 types: &[TypeDescriptor],
2004 idx_of: &HashMap<String, usize>,
2005 polymorphic: &HashSet<String>,
2006 colour: &mut Vec<u8>,
2007 order: &mut Vec<usize>,
2008 ) {
2009 if colour[i] != 0 {
2010 return; }
2012 colour[i] = 1;
2013 for l in &types[i].links {
2017 if l.is_junction_backed() || polymorphic.contains(&l.target) {
2018 continue;
2019 }
2020 if let Some(&dep) = idx_of.get(&l.target) {
2021 visit(dep, types, idx_of, polymorphic, colour, order);
2022 }
2023 }
2024 colour[i] = 2;
2025 order.push(i);
2026 }
2027
2028 for i in 0..types.len() {
2029 visit(i, types, &idx_of, polymorphic, &mut colour, &mut order);
2030 }
2031 order
2032}
2033
2034fn col_type_str(p: &crate::schema::PropertyDescriptor, owner_module: &str) -> String {
2038 crate::schema::column_ddl_type(p, owner_module)
2039}
2040
2041fn canonical_pg_type(pg_type: &str) -> String {
2047 if let Some(elem) = pg_type.strip_suffix("[]") {
2048 return format!("{}[]", canonical_pg_type(elem));
2049 }
2050 match pg_type {
2051 "int2" => "smallint",
2052 "int4" => "integer",
2053 "int8" => "bigint",
2054 "float4" => "real",
2055 "float8" => "double precision",
2056 "timestamptz" => "timestamp with time zone",
2057 "timestamp" => "timestamp without time zone",
2058 "time" => "time without time zone",
2059 other => other,
2060 }
2061 .to_string()
2062}
2063
2064fn bare_type_name(pg_type: &str) -> &str {
2074 pg_type.rsplit('.').next().unwrap_or(pg_type).trim_matches('"')
2075}
2076
2077fn pg_type_changed(target: &str, current: &str) -> bool {
2082 if target.starts_with('"') {
2083 bare_type_name(target) != bare_type_name(current)
2084 } else {
2085 canonical_pg_type(target) != canonical_pg_type(current)
2086 }
2087}
2088
2089fn diff_inner(
2092 target: &SchemaDescriptor,
2093 current: &DbState,
2094 for_migration: bool,
2095 fill_index: &HashMap<(String, String), HashSet<String>>,
2096) -> Result<Vec<MigrationStep>, String> {
2097 let mut steps = StepBuilder::new();
2098
2099 let cur_schemas: HashSet<&str> = current.schemas.iter().map(|s| s.as_str()).collect();
2100 let cur_tables: HashMap<(&str, &str), &DbTable> = current
2101 .tables
2102 .iter()
2103 .map(|t| ((t.schema.as_str(), t.name.as_str()), t))
2104 .collect();
2105 let cur_enums: HashMap<(&str, &str), &DbEnum> = current
2106 .enums
2107 .iter()
2108 .map(|e| ((e.schema.as_str(), e.name.as_str()), e))
2109 .collect();
2110 let cur_domains: HashSet<(&str, &str)> = current
2111 .domains
2112 .iter()
2113 .map(|d| (d.schema.as_str(), d.name.as_str()))
2114 .collect();
2115 let cur_composites: HashMap<(&str, &str), &DbComposite> = current
2116 .composites
2117 .iter()
2118 .map(|c| ((c.schema.as_str(), c.name.as_str()), c))
2119 .collect();
2120 let cur_sequences: HashSet<(&str, &str)> = current
2121 .sequences
2122 .iter()
2123 .map(|s| (s.schema.as_str(), s.name.as_str()))
2124 .collect();
2125 let cur_views: HashMap<(&str, &str), &str> = current
2126 .views
2127 .iter()
2128 .map(|v| ((v.schema.as_str(), v.name.as_str()), v.body_hash.as_str()))
2129 .collect();
2130 let cur_functions: HashMap<(&str, &str), &str> = current
2131 .functions
2132 .iter()
2133 .map(|f| ((f.schema.as_str(), f.name.as_str()), f.body_hash.as_str()))
2134 .collect();
2135
2136 let type_map: HashMap<String, (&str, &str)> = target
2137 .types
2138 .iter()
2139 .map(|t| {
2140 (
2141 format!("{}::{}", t.module, t.name),
2142 (t.module.as_str(), t.table.as_str()),
2143 )
2144 })
2145 .collect();
2146 let polymorphic = crate::export::polymorphic_types(target);
2147
2148 let mut target_schemas: HashSet<String> = HashSet::new();
2154 for t in &target.types {
2155 target_schemas.insert(t.module.clone());
2156 }
2157 for e in &target.enums {
2158 target_schemas.insert(e.module.clone());
2159 }
2160 for s in &target.scalars {
2161 target_schemas.insert(s.module.clone());
2162 }
2163 for nt in &target.named_tuples {
2164 target_schemas.insert(nt.module.clone());
2165 }
2166 for f in &target.functions {
2167 target_schemas.insert(f.module.clone());
2168 }
2169 for g in &target.globals {
2170 target_schemas.insert(g.module.clone());
2171 }
2172 for a in &target.aliases {
2173 target_schemas.insert(a.module.clone());
2174 }
2175
2176 for module in &target_schemas {
2178 if module == "default" {
2179 continue;
2180 } if !cur_schemas.contains(module.as_str()) {
2182 steps.push(
2183 OpKey::Module(module.clone()),
2184 Verb::Create,
2185 verbosename_module(module),
2186 DiffOp {
2187 sql: format!("CREATE SCHEMA IF NOT EXISTS {};", pg_schema(module)),
2188 non_transactional: false,
2189 },
2190 );
2191 }
2192 }
2193
2194 for e in &target.enums {
2197 match cur_enums.get(&(e.module.as_str(), e.name.as_str())) {
2198 None => {
2199 let members: Vec<String> = e
2200 .members
2201 .iter()
2202 .map(|m| format!("'{}'", m.replace('\'', "''")))
2203 .collect();
2204 steps.push(
2205 OpKey::Scalar(e.module.clone(), e.name.clone()),
2206 Verb::Create,
2207 verbosename_scalar(&e.module, &e.name),
2208 DiffOp {
2209 sql: format!(
2210 "DO $$ BEGIN CREATE TYPE {}.{} AS ENUM ({}); \
2211 EXCEPTION WHEN duplicate_object THEN NULL; END $$;",
2212 pg_schema(&e.module),
2213 qi(&e.name),
2214 members.join(", ")
2215 ),
2216 non_transactional: false,
2217 },
2218 );
2219 }
2220 Some(existing) => {
2221 let existing_set: HashSet<&str> = existing.members.iter().map(|m| m.as_str()).collect();
2222 for member in &e.members {
2223 if !existing_set.contains(member.as_str()) {
2224 steps.push(
2225 OpKey::Scalar(e.module.clone(), e.name.clone()),
2226 Verb::Alter,
2227 verbosename_scalar(&e.module, &e.name),
2228 DiffOp {
2229 sql: format!(
2230 "ALTER TYPE {}.{} ADD VALUE IF NOT EXISTS '{}';",
2231 pg_schema(&e.module),
2232 qi(&e.name),
2233 member.replace('\'', "''")
2234 ),
2235 non_transactional: false,
2236 },
2237 );
2238 }
2239 }
2240 }
2241 }
2242 }
2243
2244 for s in &target.scalars {
2247 if s.is_sequence {
2248 let seq_name = format!("{}_seq", s.name);
2249 if !cur_sequences.contains(&(s.module.as_str(), seq_name.as_str())) {
2250 let verb = if cur_domains.contains(&(s.module.as_str(), s.name.as_str())) {
2251 Verb::Alter
2252 } else {
2253 Verb::Create
2254 };
2255 steps.push(
2256 OpKey::Scalar(s.module.clone(), s.name.clone()),
2257 verb,
2258 verbosename_scalar(&s.module, &s.name),
2259 DiffOp {
2260 sql: format!(
2261 "CREATE SEQUENCE IF NOT EXISTS {}.{};",
2262 pg_schema(&s.module),
2263 qi(&seq_name)
2264 ),
2265 non_transactional: false,
2266 },
2267 );
2268 }
2269 }
2270 }
2271
2272 for s in &target.scalars {
2274 if !cur_domains.contains(&(s.module.as_str(), s.name.as_str())) {
2275 let check_clause = crate::export::scalar_check_clauses(target, &s.module, &s.name);
2276 steps.push(
2277 OpKey::Scalar(s.module.clone(), s.name.clone()),
2278 Verb::Create,
2279 verbosename_scalar(&s.module, &s.name),
2280 DiffOp {
2281 sql: format!(
2282 "DO $do$ BEGIN CREATE DOMAIN {}.{} AS {}{}; \
2283 EXCEPTION WHEN duplicate_object THEN NULL; END $do$;",
2284 pg_schema(&s.module),
2285 qi(&s.name),
2286 s.pg_type,
2287 check_clause
2288 ),
2289 non_transactional: false,
2290 },
2291 );
2292 }
2293 }
2294
2295 let expected_domain_checks = crate::export::scalar_check_constraints(target);
2301 for cur_domain in ¤t.domains {
2302 if !target
2303 .scalars
2304 .iter()
2305 .any(|s| s.module == cur_domain.schema && s.name == cur_domain.name)
2306 {
2307 continue; }
2309 let wanted: Vec<&(String, String, String, String)> = expected_domain_checks
2310 .iter()
2311 .filter(|(module, name, _, _)| *module == cur_domain.schema && *name == cur_domain.name)
2312 .collect();
2313 let domain = format!("{}.{}", pg_schema(&cur_domain.schema), qi(&cur_domain.name));
2314 let unchanged = wanted.len() == cur_domain.checks.len()
2315 && wanted
2316 .iter()
2317 .all(|(_, _, cname, _)| cur_domain.checks.iter().any(|name| name == cname));
2318 if unchanged {
2319 continue;
2320 }
2321 let mut local: Vec<DiffOp> = Vec::new();
2322 push_tx(
2327 &mut local,
2328 format!(
2329 "DO $do$ DECLARE existing record; BEGIN FOR existing IN SELECT conname FROM pg_constraint WHERE contypid = '{domain}'::regtype AND contype = 'c' LOOP EXECUTE format('ALTER DOMAIN {domain} DROP CONSTRAINT %I', existing.conname); END LOOP; END $do$;"
2330 ),
2331 );
2332 for (_, _, cname, expr) in wanted {
2333 push_tx(
2334 &mut local,
2335 format!("ALTER DOMAIN {} ADD CONSTRAINT {} CHECK ({});", domain, qi(cname), expr),
2336 );
2337 }
2338 if !local.is_empty() {
2339 steps.extend(
2340 OpKey::Scalar(cur_domain.schema.clone(), cur_domain.name.clone()),
2341 Verb::Alter,
2342 verbosename_scalar(&cur_domain.schema, &cur_domain.name),
2343 local,
2344 );
2345 }
2346 }
2347
2348 let target_tuples = target_tuple_types(target);
2351 let tuple_refs: Vec<(String, &crate::schema::tuple_type::TupleType)> = target_tuples
2352 .iter()
2353 .map(|t| (crate::schema::tuple_type::type_ref(&t.module, &t.name), t))
2354 .collect();
2355
2356 let mut rebuild: HashSet<String> = HashSet::new();
2361 for (tref, t) in &tuple_refs {
2362 match cur_composites.get(&(t.module.as_str(), t.name.as_str())) {
2363 None => steps.push(
2364 OpKey::TupleType(t.module.clone(), t.name.clone()),
2365 Verb::Create,
2366 verbosename_tuple_type(t),
2367 DiffOp {
2368 sql: create_composite_sql(t, tref, &rebuild, &tuple_refs),
2369 non_transactional: false,
2370 },
2371 ),
2372 Some(existing) if composite_changed(t, existing) => {
2373 rebuild.insert(tref.clone());
2374 }
2375 Some(_) => {}
2376 }
2377 }
2378 loop {
2381 let grown: Vec<String> = tuple_refs
2382 .iter()
2383 .filter(|(tref, t)| {
2384 !rebuild.contains(tref)
2385 && cur_composites.contains_key(&(t.module.as_str(), t.name.as_str()))
2386 && t.attributes.iter().any(|a| rebuild.contains(attribute_base_type(a)))
2387 })
2388 .map(|(tref, _)| tref.clone())
2389 .collect();
2390 if grown.is_empty() {
2391 break;
2392 }
2393 rebuild.extend(grown);
2394 }
2395
2396 if !rebuild.is_empty() {
2397 let rebuilt: Vec<(&String, &crate::schema::tuple_type::TupleType)> = tuple_refs
2403 .iter()
2404 .filter(|(tref, _)| rebuild.contains(tref))
2405 .map(|(tref, t)| (tref, *t))
2406 .collect();
2407 let mut ops: Vec<DiffOp> = Vec::new();
2408 for (_, t) in &rebuilt {
2411 push_tx(
2412 &mut ops,
2413 create_composite_sql(t, &new_type_ref(t), &rebuild, &tuple_refs),
2414 );
2415 }
2416 for (module, table, column, col_type) in tuple_columns(target) {
2418 let base = col_type.strip_suffix("[]").unwrap_or(&col_type);
2419 if !rebuild.contains(base) {
2420 continue;
2421 }
2422 let Some((_, t)) = tuple_refs.iter().find(|(tref, _)| tref == base) else {
2423 continue;
2424 };
2425 let new_base = new_type_ref(t);
2426 let new_type = if col_type.ends_with("[]") {
2427 format!("{new_base}[]")
2428 } else {
2429 new_base
2430 };
2431 push_tx(
2432 &mut ops,
2433 format!(
2434 "ALTER TABLE {} ALTER COLUMN {} TYPE {} USING {};",
2435 qn(&module, &table),
2436 qi(&column),
2437 new_type,
2438 tuple_conversion_expr(&column, &new_type),
2439 ),
2440 );
2441 }
2442 for (tref, _) in rebuilt.iter().rev() {
2445 push_tx(&mut ops, format!("DROP TYPE {};", tref));
2446 }
2447 for (_, t) in &rebuilt {
2450 push_tx(
2451 &mut ops,
2452 format!("ALTER TYPE {} RENAME TO {};", new_type_ref(t), qi(&t.name)),
2453 );
2454 }
2455 let described: Vec<String> = rebuilt.iter().map(|(_, t)| verbosename_tuple_type(t)).collect();
2456 steps.extend(OpKey::TupleTypeRebuild, Verb::Alter, described.join(", "), ops);
2457 }
2458
2459 let scalar_fn_ddls = crate::export::scalar_function_ddl_with_names(target).map_err(|e| e.to_string())?;
2461 for (module, name, ddl) in scalar_fn_ddls {
2462 let emit = if for_migration {
2463 let hash = ddl_hash(&ddl);
2464 cur_functions
2465 .get(&(module.as_str(), name.as_str()))
2466 .map(|&h| h != hash)
2467 .unwrap_or(true)
2468 } else {
2469 true
2470 };
2471 if emit {
2472 let verb = if cur_functions.contains_key(&(module.as_str(), name.as_str())) {
2473 Verb::Alter
2474 } else {
2475 Verb::Create
2476 };
2477 steps.push(
2478 OpKey::Function(module.clone(), name.clone()),
2479 verb,
2480 verbosename_function(&module, &name),
2481 DiffOp {
2482 sql: ddl,
2483 non_transactional: false,
2484 },
2485 );
2486 }
2487 }
2488
2489 let sort_order = topo_sort_types(&target.types, &polymorphic);
2491
2492 let mut new_tables: HashSet<(String, String)> = HashSet::new();
2494
2495 for &i in &sort_order {
2496 let td = &target.types[i];
2497 if td.abstract_ || td.junction {
2498 continue;
2499 }
2500 let key = (td.module.as_str(), td.table.as_str());
2501 match cur_tables.get(&key) {
2502 None => {
2503 let mut local: Vec<DiffOp> = Vec::new();
2504 emit_create_table(td, target, &mut local);
2505 steps.extend(
2506 OpKey::Table(td.module.clone(), td.table.clone()),
2507 Verb::Create,
2508 verbosename_type(&td.module, &td.name),
2509 local,
2510 );
2511 new_tables.insert((td.module.clone(), td.table.clone()));
2512 }
2513 Some(existing) => {
2514 let fill_cols = fill_index
2515 .get(&(td.module.clone(), td.table.clone()))
2516 .cloned()
2517 .unwrap_or_default();
2518 let mut local: Vec<DiffOp> = Vec::new();
2519 let mut inputs: Vec<RequiredInput> = Vec::new();
2520 emit_column_diff(td, existing, &mut local, for_migration, &fill_cols, target, &mut inputs);
2521 steps.extend_with_input(
2522 OpKey::Table(td.module.clone(), td.table.clone()),
2523 Verb::Alter,
2524 verbosename_type(&td.module, &td.name),
2525 local,
2526 inputs,
2527 );
2528 }
2529 }
2530 }
2531
2532 for &i in &sort_order {
2539 let td = &target.types[i];
2540 if td.abstract_ || td.junction {
2541 continue;
2542 }
2543 let existing = cur_tables.get(&(td.module.as_str(), td.table.as_str())).copied();
2544 let verb = if new_tables.contains(&(td.module.clone(), td.table.clone())) {
2545 Verb::Create
2546 } else {
2547 Verb::Alter
2548 };
2549 let mut local: Vec<DiffOp> = Vec::new();
2550 emit_fk_diff(td, existing, &type_map, &polymorphic, &mut local);
2551 steps.extend(
2552 OpKey::ForeignKey(td.module.clone(), td.table.clone()),
2553 verb,
2554 verbosename_type(&td.module, &td.name),
2555 local,
2556 );
2557 }
2558
2559 for (jt_module, jt_name, cname, ddl) in crate::export::junction_fk_constraints(target, &type_map) {
2564 let already_there = cur_tables
2565 .get(&(jt_module.as_str(), jt_name.as_str()))
2566 .map(|t| t.foreign_keys.iter().any(|fk| fk.constraint_name == cname))
2567 .unwrap_or(false);
2568 if already_there {
2569 continue;
2570 }
2571 let verb = if cur_tables.contains_key(&(jt_module.as_str(), jt_name.as_str())) {
2572 Verb::Alter
2573 } else {
2574 Verb::Create
2575 };
2576 let mut local: Vec<DiffOp> = Vec::new();
2577 push_tx(&mut local, ddl);
2578 steps.extend(
2579 OpKey::ForeignKey(jt_module.clone(), jt_name.clone()),
2580 verb,
2581 format!("link table '{}.{}'", jt_module, jt_name),
2582 local,
2583 );
2584 }
2585
2586 for (module, table, cname, ddl) in crate::export::check_constraints(target).map_err(|e| e.to_string())? {
2593 let already_there = cur_tables
2594 .get(&(module.as_str(), table.as_str()))
2595 .map(|t| t.checks.iter().any(|c| c.constraint_name == cname))
2596 .unwrap_or(false);
2597 if already_there {
2598 continue;
2599 }
2600 let verb = if cur_tables.contains_key(&(module.as_str(), table.as_str())) {
2601 Verb::Alter
2602 } else {
2603 Verb::Create
2604 };
2605 let mut local: Vec<DiffOp> = Vec::new();
2606 push_tx(&mut local, ddl);
2607 steps.extend(
2608 OpKey::ForeignKey(module.clone(), format!("{table}#checks")),
2609 verb,
2610 verbosename_type(&module, &table),
2611 local,
2612 );
2613 }
2614
2615 for &i in &sort_order {
2621 let td = &target.types[i];
2622 if td.abstract_ || td.junction {
2623 continue;
2624 }
2625 let owner_key = OpKey::Table(td.module.clone(), td.table.clone());
2626 let owner_verb = if new_tables.contains(&(td.module.clone(), td.table.clone())) {
2627 Verb::Create
2628 } else {
2629 Verb::Alter
2630 };
2631 let owner_desc = verbosename_type(&td.module, &td.name);
2632 for ml in &td.multilinks {
2633 let jt = format!("{}.{}", td.table, ml.name);
2634 if !cur_tables.contains_key(&(td.module.as_str(), jt.as_str())) {
2635 let mut local: Vec<DiffOp> = Vec::new();
2636 emit_junction_table(td, &ml.name, ml.through.as_deref(), target, false, &mut local);
2637 steps.extend(owner_key.clone(), owner_verb, owner_desc.clone(), local);
2638 new_tables.insert((td.module.clone(), jt));
2639 }
2640 }
2641 for l in &td.links {
2642 if !l.is_junction_backed() {
2643 continue;
2644 }
2645 let jt = format!("{}.{}", td.table, l.name);
2646 if !cur_tables.contains_key(&(td.module.as_str(), jt.as_str())) {
2647 let mut local: Vec<DiffOp> = Vec::new();
2648 emit_junction_table(td, &l.name, l.through.as_deref(), target, true, &mut local);
2649 steps.extend(owner_key.clone(), owner_verb, owner_desc.clone(), local);
2650 new_tables.insert((td.module.clone(), jt));
2651 }
2652 }
2653 }
2654
2655 for &i in &sort_order {
2657 let td = &target.types[i];
2658 if td.abstract_ || td.vector_indexes.is_empty() {
2659 continue;
2660 }
2661 let existing = cur_tables.get(&(td.module.as_str(), td.table.as_str()));
2662 let table_is_new = new_tables.contains(&(td.module.clone(), td.table.clone()));
2663 let owner_key = OpKey::Table(td.module.clone(), td.table.clone());
2664 let owner_verb = if table_is_new { Verb::Create } else { Verb::Alter };
2665 let owner_desc = verbosename_type(&td.module, &td.name);
2666
2667 for vi in &td.vector_indexes {
2668 let col = vi.column_name();
2669 if existing
2670 .map(|t| t.columns.iter().any(|c| c.name == col))
2671 .unwrap_or(false)
2672 {
2673 continue;
2674 }
2675 let mut local: Vec<DiffOp> = Vec::new();
2676 push_tx(
2677 &mut local,
2678 format!(
2679 "ALTER TABLE {} ADD COLUMN IF NOT EXISTS {} vector({});",
2680 qn(&td.module, &td.table),
2681 qi(&col),
2682 vi.dimensions
2683 ),
2684 );
2685 let idx_name = match &vi.index_name {
2686 None => format!("{}__vector__", td.table),
2687 Some(n) => format!("{}__vector_{}__", td.table, n),
2688 };
2689 let use_concurrently = for_migration && !table_is_new;
2692 let idx_sql = if use_concurrently {
2693 format!(
2694 "CREATE INDEX CONCURRENTLY IF NOT EXISTS {} ON {} USING hnsw ({} {});",
2695 qi(&idx_name),
2696 qn(&td.module, &td.table),
2697 qi(&col),
2698 vi.ops_class()
2699 )
2700 } else {
2701 format!(
2702 "CREATE INDEX IF NOT EXISTS {} ON {} USING hnsw ({} {});",
2703 qi(&idx_name),
2704 qn(&td.module, &td.table),
2705 qi(&col),
2706 vi.ops_class()
2707 )
2708 };
2709 local.push(DiffOp {
2710 sql: idx_sql,
2711 non_transactional: use_concurrently,
2712 });
2713 steps.extend(owner_key.clone(), owner_verb, owner_desc.clone(), local);
2714 }
2715 }
2716
2717 for &i in &sort_order {
2719 let td = &target.types[i];
2720 if td.abstract_ || td.search_indexes.is_empty() {
2721 continue;
2722 }
2723 let existing = cur_tables.get(&(td.module.as_str(), td.table.as_str()));
2724 let table_is_new = new_tables.contains(&(td.module.clone(), td.table.clone()));
2725 let owner_key = OpKey::Table(td.module.clone(), td.table.clone());
2726 let owner_verb = if table_is_new { Verb::Create } else { Verb::Alter };
2727 let owner_desc = verbosename_type(&td.module, &td.name);
2728
2729 for si in &td.search_indexes {
2730 if si.backend != SearchBackend::Postgres {
2731 continue;
2732 }
2733 let col = si.column_name();
2734 if existing
2735 .map(|t| t.columns.iter().any(|c| c.name == col))
2736 .unwrap_or(false)
2737 {
2738 continue;
2739 }
2740 let mut local: Vec<DiffOp> = Vec::new();
2741 let parts: Vec<String> = si
2742 .pointers
2743 .iter()
2744 .map(|sf| {
2745 format!(
2746 "setweight(to_tsvector('english', coalesce({}, '')), '{}')",
2747 qi(&sf.name),
2748 sf.weight.as_str()
2749 )
2750 })
2751 .collect();
2752 let expr = if parts.len() == 1 {
2753 parts.into_iter().next().unwrap()
2754 } else {
2755 parts.join(" || ")
2756 };
2757 push_tx(
2758 &mut local,
2759 format!(
2760 "ALTER TABLE {} ADD COLUMN IF NOT EXISTS {} tsvector GENERATED ALWAYS AS ({}) STORED;",
2761 qn(&td.module, &td.table),
2762 qi(&col),
2763 expr
2764 ),
2765 );
2766 let idx_name = match &si.index_name {
2767 None => format!("{}__search__", td.table),
2768 Some(n) => format!("{}__search_{}__", td.table, n),
2769 };
2770 let use_concurrently = for_migration && !table_is_new;
2771 let idx_sql = if use_concurrently {
2772 format!(
2773 "CREATE INDEX CONCURRENTLY IF NOT EXISTS {} ON {} USING gin ({});",
2774 qi(&idx_name),
2775 qn(&td.module, &td.table),
2776 qi(&col)
2777 )
2778 } else {
2779 format!(
2780 "CREATE INDEX IF NOT EXISTS {} ON {} USING gin ({});",
2781 qi(&idx_name),
2782 qn(&td.module, &td.table),
2783 qi(&col)
2784 )
2785 };
2786 local.push(DiffOp {
2787 sql: idx_sql,
2788 non_transactional: use_concurrently,
2789 });
2790 steps.extend(owner_key.clone(), owner_verb, owner_desc.clone(), local);
2791 }
2792 }
2793
2794 let expected_state = schema_to_db_state(target);
2800 let current_indexes: HashMap<(&str, &str), &[DbIndex]> = current
2801 .tables
2802 .iter()
2803 .map(|t| ((t.schema.as_str(), t.name.as_str()), t.indexes.as_slice()))
2804 .collect();
2805 for table in &expected_state.tables {
2806 let existing = current_indexes
2807 .get(&(table.schema.as_str(), table.name.as_str()))
2808 .copied()
2809 .unwrap_or_default();
2810 let table_is_new = !current_indexes.contains_key(&(table.schema.as_str(), table.name.as_str()));
2811 for idx in &table.indexes {
2812 if idx.columns.is_empty() && idx.key.is_none() {
2815 continue;
2816 }
2817 let already_there = match &idx.key {
2823 Some(_) => existing.iter().any(|b| b.name == idx.name),
2824 None => existing.iter().any(|b| {
2825 b.columns == idx.columns
2826 && b.is_unique == idx.is_unique
2827 && b.predicate.is_some() == idx.predicate.is_some()
2828 }),
2829 };
2830 if already_there {
2831 continue;
2832 }
2833 let use_concurrently = for_migration && !table_is_new;
2834 let concurrently = if use_concurrently { "CONCURRENTLY " } else { "" };
2835 let unique = if idx.is_unique { "UNIQUE " } else { "" };
2836 let body = match &idx.key {
2837 Some(key) => key.clone(),
2838 None => format!("({})", idx.columns.iter().map(|c| qi(c)).collect::<Vec<_>>().join(", ")),
2839 };
2840 let sql = format!(
2841 "CREATE {unique}INDEX {concurrently}IF NOT EXISTS {} ON {} {}{};",
2842 qi(&capped_index_name(idx.name.clone())),
2843 qn(&table.schema, &table.name),
2844 body,
2845 idx.predicate.clone().unwrap_or_default(),
2846 );
2847 let owner_key = OpKey::Table(table.schema.clone(), table.name.clone());
2848 let owner_verb = if table_is_new { Verb::Create } else { Verb::Alter };
2849 let owner_desc = verbosename_type(&table.schema, &table.name);
2850 steps.extend(
2851 owner_key,
2852 owner_verb,
2853 owner_desc,
2854 vec![DiffOp {
2855 sql,
2856 non_transactional: use_concurrently,
2857 }],
2858 );
2859 }
2860 }
2861
2862 for (module, name, ddl) in crate::export::interface_view_ddl_with_names(target) {
2866 let emit = if for_migration {
2867 let hash = ddl_hash(&ddl);
2868 cur_views
2869 .get(&(module.as_str(), name.as_str()))
2870 .map(|&h| h != hash)
2871 .unwrap_or(true)
2872 } else {
2873 true
2874 };
2875 if emit {
2876 let verb = if cur_views.contains_key(&(module.as_str(), name.as_str())) {
2877 Verb::Alter
2878 } else {
2879 Verb::Create
2880 };
2881 steps.push(
2882 OpKey::View(module.clone(), name.clone()),
2883 verb,
2884 verbosename_interface(&module, &name),
2885 DiffOp {
2886 sql: ddl,
2887 non_transactional: false,
2888 },
2889 );
2890 }
2891 }
2892
2893 for (module, name, ddl) in crate::export::interface_junction_view_ddl_with_names(target) {
2901 let emit = if for_migration {
2902 let hash = ddl_hash(&ddl);
2903 cur_views
2904 .get(&(module.as_str(), name.as_str()))
2905 .map(|&h| h != hash)
2906 .unwrap_or(true)
2907 } else {
2908 true
2909 };
2910 if emit {
2911 let verb = if cur_views.contains_key(&(module.as_str(), name.as_str())) {
2912 Verb::Alter
2913 } else {
2914 Verb::Create
2915 };
2916 steps.push(
2917 OpKey::View(module.clone(), name.clone()),
2918 verb,
2919 verbosename_interface(&module, &name),
2920 DiffOp {
2921 sql: ddl,
2922 non_transactional: false,
2923 },
2924 );
2925 }
2926 }
2927
2928 let target_views: HashSet<(String, String)> = crate::export::interface_view_ddl_with_names(target)
2933 .into_iter()
2934 .chain(crate::export::interface_junction_view_ddl_with_names(target))
2935 .map(|(module, name, _)| (module, name))
2936 .collect();
2937 for view in ¤t.views {
2938 if !target_views.contains(&(view.schema.clone(), view.name.clone())) {
2939 steps.push(
2940 OpKey::View(view.schema.clone(), view.name.clone()),
2941 Verb::Drop,
2942 verbosename_interface(&view.schema, &view.name),
2943 DiffOp {
2944 sql: format!("DROP VIEW IF EXISTS {};", qn(&view.schema, &view.name)),
2945 non_transactional: false,
2946 },
2947 );
2948 }
2949 }
2950
2951 let obj_fn_ddls = crate::export::object_function_ddl_with_names(target).map_err(|e| e.to_string())?;
2953 for (module, name, ddl) in obj_fn_ddls {
2954 let emit = if for_migration {
2955 let hash = ddl_hash(&ddl);
2956 cur_functions
2957 .get(&(module.as_str(), name.as_str()))
2958 .map(|&h| h != hash)
2959 .unwrap_or(true)
2960 } else {
2961 true
2962 };
2963 if emit {
2964 let verb = if cur_functions.contains_key(&(module.as_str(), name.as_str())) {
2965 Verb::Alter
2966 } else {
2967 Verb::Create
2968 };
2969 steps.push(
2970 OpKey::Function(module.clone(), name.clone()),
2971 verb,
2972 verbosename_function(&module, &name),
2973 DiffOp {
2974 sql: ddl,
2975 non_transactional: false,
2976 },
2977 );
2978 }
2979 }
2980
2981 let mut target_tables: HashSet<(String, String)> = HashSet::new();
2986 for td in &target.types {
2987 if !td.abstract_ {
2988 target_tables.insert((td.module.clone(), td.table.clone()));
2989 }
2990 if !td.abstract_ && !td.junction {
2991 for ml in &td.multilinks {
2992 target_tables.insert((td.module.clone(), format!("{}.{}", td.table, ml.name)));
2993 }
2994 for l in &td.links {
2995 if !l.is_junction_backed() {
2996 continue;
2997 }
2998 target_tables.insert((td.module.clone(), format!("{}.{}", td.table, l.name)));
2999 }
3000 }
3001 }
3002
3003 {
3007 let infos = crate::export::interface_exclusive_trigger_infos(target);
3008 let cur_trigger_map: HashMap<(&str, &str), HashSet<&str>> = current
3009 .tables
3010 .iter()
3011 .map(|t| {
3012 (
3013 (t.schema.as_str(), t.name.as_str()),
3014 t.triggers.iter().map(|n| n.as_str()).collect::<HashSet<_>>(),
3015 )
3016 })
3017 .collect();
3018
3019 let expected_trigger_map = expected_triggers(target, &type_map);
3025
3026 let owner_of = |module: &str, table: &str| -> (OpKey, Verb, String) {
3031 for &i in &sort_order {
3032 let td = &target.types[i];
3033 if td.abstract_ || td.junction {
3034 continue;
3035 }
3036 let is_owner = td.module == module
3037 && (td.table == table
3038 || td
3039 .multilinks
3040 .iter()
3041 .any(|ml| format!("{}.{}", td.table, ml.name) == table)
3042 || td
3043 .links
3044 .iter()
3045 .any(|l| l.is_junction_backed() && format!("{}.{}", td.table, l.name) == table));
3046 if is_owner {
3047 let verb = if new_tables.contains(&(td.module.clone(), td.table.clone())) {
3048 Verb::Create
3049 } else {
3050 Verb::Alter
3051 };
3052 return (
3053 OpKey::Table(td.module.clone(), td.table.clone()),
3054 verb,
3055 verbosename_type(&td.module, &td.name),
3056 );
3057 }
3058 }
3059 (
3060 OpKey::Table(module.to_string(), table.to_string()),
3061 Verb::Alter,
3062 verbosename_type(module, table),
3063 )
3064 };
3065
3066 struct PendingExclTrigger<'a> {
3067 info: &'a crate::export::ExclTriggerInfo,
3068 need_ins: bool,
3069 need_upd: bool,
3070 owner: (OpKey, Verb, String),
3071 }
3072
3073 let pending: Vec<PendingExclTrigger<'_>> = infos
3074 .iter()
3075 .filter_map(|info| {
3076 let cur = cur_trigger_map
3077 .get(&(info.impl_module.as_str(), info.impl_table.as_str()))
3078 .cloned()
3079 .unwrap_or_default();
3080 let need_ins = !cur.contains(info.ins_trigger_name.as_str());
3081 let need_upd = !cur.contains(info.upd_trigger_name.as_str());
3082 (need_ins || need_upd).then(|| PendingExclTrigger {
3083 info,
3084 need_ins,
3085 need_upd,
3086 owner: owner_of(&info.impl_module, &info.impl_table),
3087 })
3088 })
3089 .collect();
3090
3091 let mut fn_carrier: HashMap<String, usize> = HashMap::new();
3100 for (index, entry) in pending.iter().enumerate() {
3101 let position = steps.position(&entry.owner.0).unwrap_or(usize::MAX);
3102 let beats_carrier = match fn_carrier.get(&entry.info.fn_name) {
3103 Some(&carrier) => position < steps.position(&pending[carrier].owner.0).unwrap_or(usize::MAX),
3104 None => true,
3105 };
3106 if beats_carrier {
3107 fn_carrier.insert(entry.info.fn_name.clone(), index);
3108 }
3109 }
3110
3111 for (index, entry) in pending.into_iter().enumerate() {
3112 let mut local: Vec<DiffOp> = Vec::new();
3113 if fn_carrier.get(&entry.info.fn_name) == Some(&index) {
3114 push_tx(&mut local, entry.info.fn_ddl.clone());
3115 }
3116 if entry.need_ins {
3117 push_tx(&mut local, entry.info.ins_ddl.clone());
3118 }
3119 if entry.need_upd {
3120 push_tx(&mut local, entry.info.upd_ddl.clone());
3121 }
3122 let (key, verb, desc) = entry.owner;
3123 steps.extend(key, verb, desc, local);
3124 }
3125
3126 for info in crate::export::deletion_policy_trigger_infos(target, &type_map) {
3134 let cur = cur_trigger_map
3135 .get(&(info.table_module.as_str(), info.table_name.as_str()))
3136 .cloned()
3137 .unwrap_or_default();
3138 if !cur.contains(info.trigger_name.as_str()) {
3139 let (key, verb, desc) = owner_of(&info.table_module, &info.table_name);
3140 steps.extend(
3141 key,
3142 verb,
3143 desc,
3144 vec![DiffOp {
3145 sql: info.ddl.clone(),
3146 non_transactional: false,
3147 }],
3148 );
3149 }
3150 }
3151
3152 for info in crate::export::signal_trigger_infos(target) {
3159 let cur = cur_trigger_map
3160 .get(&(info.table_module.as_str(), info.table_name.as_str()))
3161 .cloned()
3162 .unwrap_or_default();
3163 if !cur.contains(info.trigger_name.as_str()) {
3164 let (key, verb, desc) = owner_of(&info.table_module, &info.table_name);
3165 steps.extend(
3166 key,
3167 verb,
3168 desc,
3169 vec![DiffOp {
3170 sql: info.ddl.clone(),
3171 non_transactional: false,
3172 }],
3173 );
3174 }
3175 }
3176
3177 for info in crate::export::user_trigger_infos(target).map_err(|e| e.to_string())? {
3188 let cur = cur_trigger_map
3189 .get(&(info.table_module.as_str(), info.table_name.as_str()))
3190 .cloned()
3191 .unwrap_or_default();
3192 if !cur.contains(info.trigger_name.as_str()) {
3193 let (key, verb, desc) = owner_of(&info.table_module, &info.table_name);
3194 steps.extend(
3195 key,
3196 verb,
3197 desc,
3198 vec![DiffOp {
3199 sql: info.ddl.clone(),
3200 non_transactional: false,
3201 }],
3202 );
3203 }
3204 }
3205
3206 let mut cache_trigger_tables: HashSet<(String, String)> = HashSet::new();
3220 for td in &target.types {
3221 if td.abstract_ || junction_table_belongs_to_an_interface(target, td) {
3222 continue;
3223 }
3224 cache_trigger_tables.insert((td.module.clone(), td.table.clone()));
3225 if !td.junction {
3226 for ml in &td.multilinks {
3227 cache_trigger_tables.insert((td.module.clone(), format!("{}.{}", td.table, ml.name)));
3228 }
3229 for l in &td.links {
3230 if !l.is_junction_backed() {
3231 continue;
3232 }
3233 cache_trigger_tables.insert((td.module.clone(), format!("{}.{}", td.table, l.name)));
3234 }
3235 }
3236 }
3237 for (module, table) in &cache_trigger_tables {
3238 let already_present = cur_trigger_map
3239 .get(&(module.as_str(), table.as_str()))
3240 .map(|t| t.contains("pylon_cache_invalidate"))
3241 .unwrap_or(false);
3242 if !already_present {
3243 let (key, verb, desc) = owner_of(module, table);
3244 steps.extend(
3245 key,
3246 verb,
3247 desc,
3248 vec![DiffOp {
3249 sql: cache_invalidate_trigger_sql(&qn(module, table)),
3250 non_transactional: false,
3251 }],
3252 );
3253 }
3254 }
3255
3256 for cur_table in ¤t.tables {
3267 let key = (cur_table.schema.clone(), cur_table.name.clone());
3268 if !target_tables.contains(&key) {
3269 continue;
3270 }
3271 let expected = expected_trigger_map.get(&key).cloned().unwrap_or_default();
3272 for trigger_name in &cur_table.triggers {
3273 if !expected.contains(trigger_name) {
3274 let (owner_key, verb, desc) = owner_of(&cur_table.schema, &cur_table.name);
3275 steps.extend(
3276 owner_key,
3277 verb,
3278 desc,
3279 vec![
3280 DiffOp {
3281 sql: format!(
3282 "DROP TRIGGER IF EXISTS {} ON {};",
3283 qi(trigger_name),
3284 qn(&cur_table.schema, &cur_table.name)
3285 ),
3286 non_transactional: false,
3287 },
3288 DiffOp {
3306 sql: format!("DROP FUNCTION IF EXISTS {}();", qn(&cur_table.schema, trigger_name)),
3307 non_transactional: false,
3308 },
3309 ],
3310 );
3311 }
3312 }
3313 }
3314 }
3315
3316 let expected_checks: HashSet<(String, String, String)> = crate::export::check_constraints(target)
3323 .map_err(|e| e.to_string())?
3324 .into_iter()
3325 .map(|(module, table, name, _)| (module, table, name))
3326 .collect();
3327 for cur_table in ¤t.tables {
3328 if !target_tables.contains(&(cur_table.schema.clone(), cur_table.name.clone())) {
3329 continue; }
3331 for check in &cur_table.checks {
3332 if !is_generated_check_name(&check.constraint_name) {
3333 continue;
3334 }
3335 let key = (
3336 cur_table.schema.clone(),
3337 cur_table.name.clone(),
3338 check.constraint_name.clone(),
3339 );
3340 if expected_checks.contains(&key) {
3341 continue;
3342 }
3343 steps.extend(
3344 OpKey::ForeignKey(cur_table.schema.clone(), format!("{}#checks", cur_table.name)),
3345 Verb::Alter,
3346 verbosename_type(&cur_table.schema, &cur_table.name),
3347 vec![DiffOp {
3348 sql: format!(
3349 "ALTER TABLE {} DROP CONSTRAINT IF EXISTS {};",
3350 qn(&cur_table.schema, &cur_table.name),
3351 qi(&check.constraint_name)
3352 ),
3353 non_transactional: false,
3354 }],
3355 );
3356 }
3357 }
3358
3359 let polymorphic_targets = crate::export::polymorphic_types(target);
3362 let mut unwanted_fks: HashSet<(String, String, String)> = HashSet::new();
3363 for t in target.types.iter().filter(|t| !t.abstract_ && !t.junction) {
3364 for l in &t.links {
3365 if !polymorphic_targets.contains(&l.target) {
3366 continue;
3367 }
3368 unwanted_fks.insert(if l.is_junction_backed() {
3369 (
3370 t.module.clone(),
3371 format!("{}.{}", t.table, l.name),
3372 format!("{}_{}_target_fkey", t.table, l.name),
3373 )
3374 } else {
3375 (
3376 t.module.clone(),
3377 t.table.clone(),
3378 format!("{}_{}_fkey", t.table, l.name),
3379 )
3380 });
3381 }
3382 for ml in t
3383 .multilinks
3384 .iter()
3385 .filter(|ml| polymorphic_targets.contains(&ml.target))
3386 {
3387 unwanted_fks.insert((
3388 t.module.clone(),
3389 format!("{}.{}", t.table, ml.name),
3390 format!("{}_{}_target_fkey", t.table, ml.name),
3391 ));
3392 }
3393 }
3394 for cur_table in ¤t.tables {
3395 for fk in &cur_table.foreign_keys {
3396 let key = (
3397 cur_table.schema.clone(),
3398 cur_table.name.clone(),
3399 fk.constraint_name.clone(),
3400 );
3401 let references_a_table = target
3403 .types
3404 .iter()
3405 .any(|t| !t.abstract_ && t.module == fk.ref_schema && t.table == fk.ref_table);
3406 if !unwanted_fks.contains(&key) || !references_a_table {
3407 continue;
3408 }
3409 steps.extend(
3410 OpKey::ForeignKey(cur_table.schema.clone(), cur_table.name.clone()),
3411 Verb::Alter,
3412 verbosename_type(&cur_table.schema, &cur_table.name),
3413 vec![DiffOp {
3414 sql: format!(
3415 "ALTER TABLE {} DROP CONSTRAINT IF EXISTS {};",
3416 qn(&cur_table.schema, &cur_table.name),
3417 qi(&fk.constraint_name)
3418 ),
3419 non_transactional: false,
3420 }],
3421 );
3422 }
3423 }
3424
3425 for cur_table in ¤t.tables {
3427 let key = (cur_table.schema.clone(), cur_table.name.clone());
3428 if !target_tables.contains(&key) {
3429 steps.push(
3430 OpKey::Table(cur_table.schema.clone(), cur_table.name.clone()),
3431 Verb::Drop,
3432 verbosename_type(&cur_table.schema, &cur_table.name),
3433 DiffOp {
3434 sql: format!(
3435 "DROP TABLE IF EXISTS {} CASCADE;",
3436 qn(&cur_table.schema, &cur_table.name)
3437 ),
3438 non_transactional: false,
3439 },
3440 );
3441 }
3442 }
3443
3444 let target_enum_set: HashSet<(String, String)> = target
3446 .enums
3447 .iter()
3448 .map(|e| (e.module.clone(), e.name.clone()))
3449 .collect();
3450 for cur_enum in ¤t.enums {
3451 if !target_enum_set.contains(&(cur_enum.schema.clone(), cur_enum.name.clone())) {
3452 steps.push(
3453 OpKey::Scalar(cur_enum.schema.clone(), cur_enum.name.clone()),
3454 Verb::Drop,
3455 verbosename_scalar(&cur_enum.schema, &cur_enum.name),
3456 DiffOp {
3457 sql: format!(
3458 "DROP TYPE IF EXISTS {}.{} CASCADE;",
3459 pg_schema(&cur_enum.schema),
3460 qi(&cur_enum.name)
3461 ),
3462 non_transactional: false,
3463 },
3464 );
3465 }
3466 }
3467
3468 let target_composite_set: HashSet<(String, String)> = target_tuples
3474 .iter()
3475 .map(|t| (t.module.clone(), t.name.clone()))
3476 .collect();
3477 for cur_composite in ¤t.composites {
3478 if !target_composite_set.contains(&(cur_composite.schema.clone(), cur_composite.name.clone())) {
3479 steps.push(
3480 OpKey::TupleType(cur_composite.schema.clone(), cur_composite.name.clone()),
3481 Verb::Drop,
3482 format!("tuple type '{}::{}'", cur_composite.schema, cur_composite.name),
3483 DiffOp {
3484 sql: format!(
3485 "DROP TYPE IF EXISTS {}.{};",
3486 pg_schema(&cur_composite.schema),
3487 qi(&cur_composite.name)
3488 ),
3489 non_transactional: false,
3490 },
3491 );
3492 }
3493 }
3494
3495 let target_domain_set: HashSet<(String, String)> = target
3497 .scalars
3498 .iter()
3499 .map(|s| (s.module.clone(), s.name.clone()))
3500 .collect();
3501 for cur_domain in ¤t.domains {
3502 if !target_domain_set.contains(&(cur_domain.schema.clone(), cur_domain.name.clone())) {
3503 steps.push(
3504 OpKey::Scalar(cur_domain.schema.clone(), cur_domain.name.clone()),
3505 Verb::Drop,
3506 verbosename_scalar(&cur_domain.schema, &cur_domain.name),
3507 DiffOp {
3508 sql: format!(
3509 "DROP DOMAIN IF EXISTS {}.{} CASCADE;",
3510 pg_schema(&cur_domain.schema),
3511 qi(&cur_domain.name)
3512 ),
3513 non_transactional: false,
3514 },
3515 );
3516 }
3517 }
3518
3519 let target_sequence_set: HashSet<(String, String)> = target
3522 .scalars
3523 .iter()
3524 .filter(|s| s.is_sequence)
3525 .map(|s| (s.module.clone(), format!("{}_seq", s.name)))
3526 .collect();
3527 for cur_seq in ¤t.sequences {
3528 if !target_sequence_set.contains(&(cur_seq.schema.clone(), cur_seq.name.clone())) {
3529 let scalar_name = cur_seq.name.strip_suffix("_seq").unwrap_or(&cur_seq.name).to_string();
3530 steps.push(
3531 OpKey::Scalar(cur_seq.schema.clone(), scalar_name.clone()),
3532 Verb::Drop,
3533 verbosename_scalar(&cur_seq.schema, &scalar_name),
3534 DiffOp {
3535 sql: format!(
3536 "DROP SEQUENCE IF EXISTS {}.{};",
3537 pg_schema(&cur_seq.schema),
3538 qi(&cur_seq.name)
3539 ),
3540 non_transactional: false,
3541 },
3542 );
3543 }
3544 }
3545
3546 for module in ¤t.schemas {
3548 if module == "default" {
3549 continue;
3550 } if !target_schemas.contains(module) {
3552 steps.push(
3553 OpKey::Module(module.clone()),
3554 Verb::Drop,
3555 verbosename_module(module),
3556 DiffOp {
3557 sql: format!("DROP SCHEMA IF EXISTS {} CASCADE;", pg_schema(module)),
3558 non_transactional: false,
3559 },
3560 );
3561 }
3562 }
3563
3564 Ok(steps.finish())
3565}
3566
3567fn push_tx(ops: &mut Vec<DiffOp>, sql: String) {
3568 ops.push(DiffOp {
3569 sql,
3570 non_transactional: false,
3571 });
3572}
3573
3574fn resolve_default(p: &crate::schema::PropertyDescriptor, schema: &SchemaDescriptor) -> Option<String> {
3580 if let Some(sql) = &p.default_sql {
3581 return Some(sql.clone());
3582 }
3583 if let Some(pyql) = &p.default_pyql {
3584 return crate::ir::column_default_sql(pyql, schema);
3585 }
3586 None
3587}
3588
3589#[cfg(test)]
3592pub(crate) fn resolve_default_for_test(
3593 p: &crate::schema::PropertyDescriptor,
3594 schema: &SchemaDescriptor,
3595) -> Option<String> {
3596 resolve_default(p, schema)
3597}
3598
3599fn resolve_link_default(l: &crate::schema::LinkDescriptor, schema: &SchemaDescriptor) -> Option<String> {
3600 if let Some(pyql) = &l.default_pyql {
3601 return crate::ir::column_default_sql(pyql, schema);
3602 }
3603 None
3604}
3605
3606fn emit_create_table(td: &TypeDescriptor, schema: &SchemaDescriptor, ops: &mut Vec<DiffOp>) {
3609 let mut lines: Vec<String> = Vec::new();
3610 for p in &td.properties {
3611 let not_null = if p.nullable { "" } else { " NOT NULL" };
3612 let default = resolve_default(p, schema)
3613 .map(|d| format!(" DEFAULT {}", d))
3614 .unwrap_or_default();
3615 lines.push(format!(
3616 " {} {}{}{}",
3617 qi(&p.name),
3618 col_type_str(p, &td.module),
3619 not_null,
3620 default
3621 ));
3622 }
3623 for l in &td.links {
3624 if l.is_junction_backed() {
3625 continue;
3626 }
3627 let not_null = if l.nullable { "" } else { " NOT NULL" };
3628 let default = resolve_link_default(l, schema)
3629 .map(|d| format!(" DEFAULT {}", d))
3630 .unwrap_or_default();
3631 lines.push(format!(
3632 " {} uuid{}{}",
3633 qi(&format!("{}_id", l.name)),
3634 not_null,
3635 default
3636 ));
3637 }
3638 let pk_cols: Vec<String> = td.properties.iter().filter(|p| p.is_pk).map(|p| qi(&p.name)).collect();
3639 if !pk_cols.is_empty() {
3640 lines.push(format!(" PRIMARY KEY ({})", pk_cols.join(", ")));
3641 }
3642 push_tx(
3643 ops,
3644 format!(
3645 "CREATE TABLE IF NOT EXISTS {} (\n{}\n);",
3646 qn(&td.module, &td.table),
3647 lines.join(",\n")
3648 ),
3649 );
3650}
3651
3652fn cache_invalidate_trigger_sql(qualified_table: &str) -> String {
3657 format!(
3658 "CREATE OR REPLACE TRIGGER pylon_cache_invalidate\n AFTER INSERT OR UPDATE OR DELETE ON {}\n FOR EACH STATEMENT EXECUTE FUNCTION _pylon.notify_cache_invalidate();",
3659 qualified_table
3660 )
3661}
3662
3663fn emit_column_diff(
3670 td: &TypeDescriptor,
3671 existing: &DbTable,
3672 ops: &mut Vec<DiffOp>,
3673 for_migration: bool,
3674 fill_cols: &HashSet<String>,
3675 schema: &SchemaDescriptor,
3676 required_input: &mut Vec<RequiredInput>,
3677) {
3678 let existing_col_map: HashMap<&str, &DbColumn> = existing.columns.iter().map(|c| (c.name.as_str(), c)).collect();
3679
3680 for p in &td.properties {
3682 if existing_col_map.contains_key(p.name.as_str()) {
3683 continue;
3684 }
3685 let eff_default = resolve_default(p, schema);
3686 let needs_fill = for_migration && !p.nullable && eff_default.is_none() && fill_cols.contains(&p.name);
3687 let not_null = if p.nullable || needs_fill { "" } else { " NOT NULL" };
3688 let default = eff_default.map(|d| format!(" DEFAULT {}", d)).unwrap_or_default();
3689 push_tx(
3690 ops,
3691 format!(
3692 "ALTER TABLE {} ADD COLUMN IF NOT EXISTS {} {}{}{};",
3693 qn(&td.module, &td.table),
3694 qi(&p.name),
3695 col_type_str(p, &td.module),
3696 not_null,
3697 default
3698 ),
3699 );
3700 }
3701 for l in &td.links {
3702 if l.is_junction_backed() {
3703 continue;
3704 }
3705 let col = format!("{}_id", l.name);
3706 if existing_col_map.contains_key(col.as_str()) {
3707 continue;
3708 }
3709 let eff_default = resolve_link_default(l, schema);
3710 let needs_fill = for_migration && !l.nullable && eff_default.is_none() && fill_cols.contains(&col);
3711 let not_null = if l.nullable || needs_fill { "" } else { " NOT NULL" };
3712 let default = eff_default.map(|d| format!(" DEFAULT {}", d)).unwrap_or_default();
3713 push_tx(
3714 ops,
3715 format!(
3716 "ALTER TABLE {} ADD COLUMN IF NOT EXISTS {} uuid{}{};",
3717 qn(&td.module, &td.table),
3718 qi(&col),
3719 not_null,
3720 default
3721 ),
3722 );
3723 }
3724
3725 let type_changes: Vec<(&str, String, bool, bool)> = td
3732 .properties
3733 .iter()
3734 .filter_map(|p| {
3735 let cur = existing_col_map.get(p.name.as_str())?;
3736 if cur.is_generated {
3737 return None;
3738 }
3739 let target_type = col_type_str(p, &td.module);
3740 pg_type_changed(&target_type, &cur.pg_type).then_some((
3741 p.name.as_str(),
3742 target_type,
3743 cur.column_default.is_some(),
3744 crate::schema::tuple_type::property_column_type(p, &td.module).is_some(),
3745 ))
3746 })
3747 .collect();
3748 if !type_changes.is_empty() {
3749 let affected_views: Vec<(String, String, String)> = crate::export::interface_view_ddl_with_names(schema)
3754 .into_iter()
3755 .filter(|(m, n, _)| td.interfaces.contains(&format!("{}::{}", m, n)))
3756 .collect();
3757 for (m, n, _) in &affected_views {
3758 push_tx(ops, format!("DROP VIEW IF EXISTS {};", qn(m, n)));
3759 }
3760 for (col, target_type, has_default, to_tuple) in &type_changes {
3761 if *has_default {
3767 push_tx(
3768 ops,
3769 format!(
3770 "ALTER TABLE {} ALTER COLUMN {} DROP DEFAULT;",
3771 qn(&td.module, &td.table),
3772 qi(col)
3773 ),
3774 );
3775 }
3776 let placeholder = format!("cast_expr__{col}");
3784 let default_expr = if *to_tuple {
3789 tuple_conversion_expr(col, target_type)
3790 } else if enum_target(schema, target_type) {
3791 let text_type = if target_type.ends_with("[]") { "text[]" } else { "text" };
3792 format!("{}::{text_type}::{target_type}", qi(col))
3793 } else {
3794 format!("{}::{target_type}", qi(col))
3795 };
3796 required_input.push(RequiredInput {
3797 placeholder: placeholder.clone(),
3798 prompt: format!(
3799 "Please specify a conversion expression to alter the type of property '{col}' of {}",
3800 verbosename_type(&td.module, &td.name),
3801 ),
3802 default_expr,
3803 type_name: format!("{}::{}", td.module, td.name),
3804 });
3805 push_tx(
3806 ops,
3807 format!(
3808 "ALTER TABLE {} ALTER COLUMN {} TYPE {} USING \\({});",
3809 qn(&td.module, &td.table),
3810 qi(col),
3811 target_type,
3812 placeholder
3813 ),
3814 );
3815 }
3816 for (_, _, ddl) in &affected_views {
3817 push_tx(ops, ddl.clone());
3818 }
3819 }
3820
3821 for p in &td.properties {
3823 let Some(cur) = existing_col_map.get(p.name.as_str()) else {
3824 continue;
3825 };
3826 if cur.is_generated {
3827 continue;
3828 }
3829 if !cur.nullable && p.nullable {
3830 push_tx(
3832 ops,
3833 format!(
3834 "ALTER TABLE {} ALTER COLUMN {} DROP NOT NULL;",
3835 qn(&td.module, &td.table),
3836 qi(&p.name)
3837 ),
3838 );
3839 } else if cur.nullable && !p.nullable && !for_migration {
3840 push_tx(
3842 ops,
3843 format!(
3844 "ALTER TABLE {} ALTER COLUMN {} SET NOT NULL;",
3845 qn(&td.module, &td.table),
3846 qi(&p.name)
3847 ),
3848 );
3849 }
3852
3853 let target_default = resolve_default(p, schema);
3855 let db_default = cur.column_default.as_deref();
3856 match (&target_default, db_default) {
3857 (Some(want), Some(have)) if want != have => {
3858 push_tx(
3859 ops,
3860 format!(
3861 "ALTER TABLE {} ALTER COLUMN {} SET DEFAULT {};",
3862 qn(&td.module, &td.table),
3863 qi(&p.name),
3864 want
3865 ),
3866 );
3867 }
3868 (Some(want), None) => {
3869 push_tx(
3870 ops,
3871 format!(
3872 "ALTER TABLE {} ALTER COLUMN {} SET DEFAULT {};",
3873 qn(&td.module, &td.table),
3874 qi(&p.name),
3875 want
3876 ),
3877 );
3878 }
3879 (None, Some(_)) => {
3880 push_tx(
3881 ops,
3882 format!(
3883 "ALTER TABLE {} ALTER COLUMN {} DROP DEFAULT;",
3884 qn(&td.module, &td.table),
3885 qi(&p.name)
3886 ),
3887 );
3888 }
3889 _ => {}
3890 }
3891 }
3892 for l in &td.links {
3893 if l.is_junction_backed() {
3894 continue;
3895 }
3896 let col = format!("{}_id", l.name);
3897 let Some(cur) = existing_col_map.get(col.as_str()) else {
3898 continue;
3899 };
3900 if !cur.nullable && l.nullable {
3901 push_tx(
3902 ops,
3903 format!(
3904 "ALTER TABLE {} ALTER COLUMN {} DROP NOT NULL;",
3905 qn(&td.module, &td.table),
3906 qi(&col)
3907 ),
3908 );
3909 } else if cur.nullable && !l.nullable && !for_migration {
3910 push_tx(
3911 ops,
3912 format!(
3913 "ALTER TABLE {} ALTER COLUMN {} SET NOT NULL;",
3914 qn(&td.module, &td.table),
3915 qi(&col)
3916 ),
3917 );
3918 }
3919
3920 let target_default = resolve_link_default(l, schema);
3921 let db_default = cur.column_default.as_deref();
3922 match (&target_default, db_default) {
3923 (Some(want), Some(have)) if want != have => {
3924 push_tx(
3925 ops,
3926 format!(
3927 "ALTER TABLE {} ALTER COLUMN {} SET DEFAULT {};",
3928 qn(&td.module, &td.table),
3929 qi(&col),
3930 want
3931 ),
3932 );
3933 }
3934 (Some(want), None) => {
3935 push_tx(
3936 ops,
3937 format!(
3938 "ALTER TABLE {} ALTER COLUMN {} SET DEFAULT {};",
3939 qn(&td.module, &td.table),
3940 qi(&col),
3941 want
3942 ),
3943 );
3944 }
3945 (None, Some(_)) => {
3946 push_tx(
3947 ops,
3948 format!(
3949 "ALTER TABLE {} ALTER COLUMN {} DROP DEFAULT;",
3950 qn(&td.module, &td.table),
3951 qi(&col)
3952 ),
3953 );
3954 }
3955 _ => {}
3956 }
3957 }
3958
3959 let target_cols: HashSet<String> = td
3961 .properties
3962 .iter()
3963 .map(|p| p.name.clone())
3964 .chain(
3965 td.links
3966 .iter()
3967 .filter(|l| !l.is_junction_backed())
3968 .map(|l| format!("{}_id", l.name)),
3969 )
3970 .collect();
3971 for col in &existing.columns {
3972 let n = col.name.as_str();
3973 if target_cols.contains(n) {
3974 continue;
3975 }
3976 if n.starts_with("__") && n.ends_with("__") {
3977 continue;
3978 }
3979 push_tx(
3980 ops,
3981 format!(
3982 "ALTER TABLE {} DROP COLUMN IF EXISTS {};",
3983 qn(&td.module, &td.table),
3984 qi(n)
3985 ),
3986 );
3987 }
3988}
3989
3990fn emit_fk_diff(
3993 td: &TypeDescriptor,
3994 existing: Option<&DbTable>,
3995 type_map: &HashMap<String, (&str, &str)>,
3996 polymorphic: &HashSet<String>,
3997 ops: &mut Vec<DiffOp>,
3998) {
3999 use crate::schema::{DeleteAction, DeleteSide};
4000
4001 let existing_fk_names: HashSet<&str> = existing
4002 .map(|e| e.foreign_keys.iter().map(|fk| fk.constraint_name.as_str()).collect())
4003 .unwrap_or_default();
4004
4005 for l in &td.links {
4006 if l.is_junction_backed() {
4007 continue;
4008 }
4009 if polymorphic.contains(&l.target) {
4012 continue;
4013 }
4014 let cname = format!("{}_{}_fkey", td.table, l.name);
4015 if existing_fk_names.contains(cname.as_str()) {
4016 continue;
4017 }
4018 let Some((tgt_module, tgt_table)) = type_map.get(&l.target) else {
4019 continue;
4020 };
4021 let needs_deferred = crate::export::needs_deferred_target_fk(&l.on_delete);
4028 let on_delete = l
4029 .on_delete
4030 .iter()
4031 .find(|p| p.side == DeleteSide::Target)
4032 .map(|p| match &p.action {
4033 DeleteAction::Restrict if needs_deferred => " DEFERRABLE INITIALLY DEFERRED",
4034 DeleteAction::Restrict => " ON DELETE RESTRICT",
4035 DeleteAction::DeferredRestrict => " DEFERRABLE INITIALLY DEFERRED",
4036 DeleteAction::DeleteSource => " ON DELETE CASCADE",
4037 DeleteAction::Allow => " ON DELETE SET NULL",
4038 _ => " ON DELETE RESTRICT",
4039 })
4040 .unwrap_or(if needs_deferred {
4041 " DEFERRABLE INITIALLY DEFERRED"
4042 } else {
4043 " ON DELETE RESTRICT"
4044 });
4045 push_tx(
4046 ops,
4047 format!(
4048 "ALTER TABLE {} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {}(id){};",
4049 qn(&td.module, &td.table),
4050 qi(&cname),
4051 qi(&format!("{}_id", l.name)),
4052 qn(tgt_module, tgt_table),
4053 on_delete
4054 ),
4055 );
4056 }
4057}
4058
4059#[allow(clippy::too_many_arguments)]
4067fn emit_junction_table(
4068 td: &TypeDescriptor,
4069 ml_name: &str,
4070 through: Option<&str>,
4071 schema: &SchemaDescriptor,
4072 single: bool,
4073 ops: &mut Vec<DiffOp>,
4074) {
4075 let jt_name = format!("{}.{}", td.table, ml_name);
4076 let src_on_delete = " ON DELETE CASCADE";
4077 let mut col_lines = format!(
4080 " source uuid NOT NULL REFERENCES {}(id){},\n target uuid NOT NULL",
4081 qn(&td.module, &td.table),
4082 src_on_delete,
4083 );
4084
4085 if let Some(through_qname) = through
4087 && let Some(through_td) = schema
4088 .types
4089 .iter()
4090 .find(|t| format!("{}::{}", t.module, t.name) == through_qname && t.junction)
4091 {
4092 for p in &through_td.properties {
4093 if p.name == "id" {
4094 continue;
4095 }
4096 let not_null = if p.nullable { "" } else { " NOT NULL" };
4097 col_lines.push_str(&format!(
4098 ",\n {} {}{}",
4099 qi(&p.name),
4100 col_type_str(p, &through_td.module),
4101 not_null
4102 ));
4103 }
4104 }
4105
4106 let pk_clause = if single {
4107 "PRIMARY KEY (source)"
4108 } else {
4109 "PRIMARY KEY (source, target)"
4110 };
4111 push_tx(
4114 ops,
4115 format!(
4116 "CREATE TABLE IF NOT EXISTS {} (\n{},\n {}\n);",
4117 qn(&td.module, &jt_name),
4118 col_lines,
4119 pk_clause,
4120 ),
4121 );
4122}
4123
4124fn diff_states_inner(before: &DbState, after: &DbState) -> Vec<DiffOp> {
4127 let mut ops: Vec<DiffOp> = Vec::new();
4128
4129 let before_schemas: HashSet<&str> = before.schemas.iter().map(|s| s.as_str()).collect();
4130 let before_tables: HashMap<(&str, &str), &DbTable> = before
4131 .tables
4132 .iter()
4133 .map(|t| ((t.schema.as_str(), t.name.as_str()), t))
4134 .collect();
4135 let before_enums: HashMap<(&str, &str), &DbEnum> = before
4136 .enums
4137 .iter()
4138 .map(|e| ((e.schema.as_str(), e.name.as_str()), e))
4139 .collect();
4140 let before_domains: HashSet<(&str, &str)> = before
4141 .domains
4142 .iter()
4143 .map(|d| (d.schema.as_str(), d.name.as_str()))
4144 .collect();
4145
4146 for schema in &after.schemas {
4148 if schema == "default" {
4149 continue;
4150 } if !before_schemas.contains(schema.as_str()) {
4152 push_tx(&mut ops, format!("CREATE SCHEMA IF NOT EXISTS {};", pg_schema(schema)));
4153 }
4154 }
4155
4156 for e in &after.enums {
4158 match before_enums.get(&(e.schema.as_str(), e.name.as_str())) {
4159 None => {
4160 let members: Vec<String> = e
4161 .members
4162 .iter()
4163 .map(|m| format!("'{}'", m.replace('\'', "''")))
4164 .collect();
4165 push_tx(
4166 &mut ops,
4167 format!(
4168 "DO $$ BEGIN CREATE TYPE {}.{} AS ENUM ({}); \
4169 EXCEPTION WHEN duplicate_object THEN NULL; END $$;",
4170 pg_schema(&e.schema),
4171 qi(&e.name),
4172 members.join(", ")
4173 ),
4174 );
4175 }
4176 Some(existing) => {
4177 let existing_set: HashSet<&str> = existing.members.iter().map(|m| m.as_str()).collect();
4178 for member in &e.members {
4179 if !existing_set.contains(member.as_str()) {
4180 push_tx(
4181 &mut ops,
4182 format!(
4183 "ALTER TYPE {}.{} ADD VALUE IF NOT EXISTS '{}';",
4184 pg_schema(&e.schema),
4185 qi(&e.name),
4186 member.replace('\'', "''")
4187 ),
4188 );
4189 }
4190 }
4191 }
4192 }
4193 }
4194
4195 let before_composites: HashMap<(&str, &str), &DbComposite> = before
4200 .composites
4201 .iter()
4202 .map(|c| ((c.schema.as_str(), c.name.as_str()), c))
4203 .collect();
4204 let mut replaced: Vec<&DbComposite> = Vec::new();
4205 for c in &after.composites {
4206 match before_composites.get(&(c.schema.as_str(), c.name.as_str())) {
4207 None => push_tx(&mut ops, create_db_composite_sql(c, &qn(&c.schema, &c.name))),
4208 Some(existing) if db_composite_changed(c, existing) => replaced.push(c),
4209 Some(_) => {}
4210 }
4211 }
4212 if !replaced.is_empty() {
4213 for c in &replaced {
4216 push_tx(&mut ops, create_db_composite_sql(c, &new_db_type_ref(c)));
4217 }
4218 for c in &replaced {
4219 for (schema, table, column, is_array) in db_columns_of_composite(after, c) {
4220 let new_type = match is_array {
4221 true => format!("{}[]", new_db_type_ref(c)),
4222 false => new_db_type_ref(c),
4223 };
4224 push_tx(
4225 &mut ops,
4226 format!(
4227 "ALTER TABLE {} ALTER COLUMN {} TYPE {} USING {};",
4228 qn(&schema, &table),
4229 qi(&column),
4230 new_type,
4231 tuple_conversion_expr(&column, &new_type),
4232 ),
4233 );
4234 }
4235 }
4236 for c in replaced.iter().rev() {
4237 push_tx(&mut ops, format!("DROP TYPE {};", qn(&c.schema, &c.name)));
4238 }
4239 for c in &replaced {
4240 push_tx(
4241 &mut ops,
4242 format!("ALTER TYPE {} RENAME TO {};", new_db_type_ref(c), qi(&c.name)),
4243 );
4244 }
4245 }
4246 for c in &before.composites {
4247 let still_there = after
4248 .composites
4249 .iter()
4250 .any(|a| a.schema == c.schema && a.name == c.name);
4251 if !still_there {
4252 push_tx(&mut ops, format!("DROP TYPE IF EXISTS {};", qn(&c.schema, &c.name)));
4253 }
4254 }
4255
4256 for d in &after.domains {
4258 if !before_domains.contains(&(d.schema.as_str(), d.name.as_str())) {
4259 push_tx(
4262 &mut ops,
4263 format!(
4264 "-- TODO: recreate domain {}.{} (reconstruct DDL from source migrations)",
4265 pg_schema(&d.schema),
4266 qi(&d.name)
4267 ),
4268 );
4269 }
4270 }
4271
4272 let mut new_tables: HashSet<(String, String)> = HashSet::new();
4274 for t in &after.tables {
4275 let key = (t.schema.as_str(), t.name.as_str());
4276 match before_tables.get(&key) {
4277 None => {
4278 emit_create_table_from_db(t, &mut ops);
4280 new_tables.insert((t.schema.clone(), t.name.clone()));
4281 }
4282 Some(before_t) => {
4283 emit_column_diff_from_db(t, before_t, &mut ops);
4285 }
4286 }
4287 }
4288
4289 for t in &after.tables {
4291 if let Some(before_t) = before_tables.get(&(t.schema.as_str(), t.name.as_str())) {
4292 let before_fk_names: HashSet<&str> = before_t
4293 .foreign_keys
4294 .iter()
4295 .map(|fk| fk.constraint_name.as_str())
4296 .collect();
4297 for fk in &t.foreign_keys {
4298 if !before_fk_names.contains(fk.constraint_name.as_str()) {
4299 push_tx(
4302 &mut ops,
4303 format!(
4304 "ALTER TABLE {}.{} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {}.{}(id);",
4305 pg_schema(&t.schema),
4306 qi(&t.name),
4307 qi(&fk.constraint_name),
4308 qi(&fk.local_column),
4309 pg_schema(&fk.ref_schema),
4310 qi(&fk.ref_table)
4311 ),
4312 );
4313 }
4314 }
4315 }
4316 }
4317
4318 for t in &after.tables {
4320 let table_is_new = new_tables.contains(&(t.schema.clone(), t.name.clone()));
4321 let before_indexes = before_tables
4322 .get(&(t.schema.as_str(), t.name.as_str()))
4323 .map(|bt| bt.indexes.as_slice())
4324 .unwrap_or_default();
4325 for idx in &t.indexes {
4326 let already_there = if idx.columns.is_empty() {
4332 before_indexes.iter().any(|b| b.name == idx.name)
4333 } else {
4334 before_indexes
4335 .iter()
4336 .any(|b| b.columns == idx.columns && b.is_unique == idx.is_unique)
4337 };
4338 if already_there {
4339 continue;
4340 }
4341 if idx.columns.is_empty() {
4344 continue;
4345 }
4346 let use_concurrently = !table_is_new;
4347 let concurrently = if use_concurrently { "CONCURRENTLY " } else { "" };
4348 let unique = if idx.is_unique { "UNIQUE " } else { "" };
4349 let idx_sql = format!(
4350 "CREATE {unique}INDEX {concurrently}IF NOT EXISTS {} ON {}.{} ({});",
4351 qi(&idx.name),
4352 pg_schema(&t.schema),
4353 qi(&t.name),
4354 idx.columns.iter().map(|c| qi(c)).collect::<Vec<_>>().join(", "),
4355 );
4356 ops.push(DiffOp {
4357 sql: idx_sql,
4358 non_transactional: use_concurrently,
4359 });
4360 }
4361 }
4362
4363 let after_tables: HashSet<(&str, &str)> = after
4365 .tables
4366 .iter()
4367 .map(|t| (t.schema.as_str(), t.name.as_str()))
4368 .collect();
4369 for t in &before.tables {
4370 if !after_tables.contains(&(t.schema.as_str(), t.name.as_str())) {
4371 push_tx(
4372 &mut ops,
4373 format!("DROP TABLE IF EXISTS {}.{} CASCADE;", pg_schema(&t.schema), qi(&t.name)),
4374 );
4375 }
4376 }
4377
4378 let after_enum_set: HashSet<(&str, &str)> = after
4380 .enums
4381 .iter()
4382 .map(|e| (e.schema.as_str(), e.name.as_str()))
4383 .collect();
4384 for e in &before.enums {
4385 if !after_enum_set.contains(&(e.schema.as_str(), e.name.as_str())) {
4386 push_tx(
4387 &mut ops,
4388 format!("DROP TYPE IF EXISTS {}.{} CASCADE;", pg_schema(&e.schema), qi(&e.name)),
4389 );
4390 }
4391 }
4392
4393 let after_schema_set: HashSet<&str> = after.schemas.iter().map(|s| s.as_str()).collect();
4395 for schema in &before.schemas {
4396 if schema == "default" {
4397 continue;
4398 } if !after_schema_set.contains(schema.as_str()) {
4400 push_tx(
4401 &mut ops,
4402 format!("DROP SCHEMA IF EXISTS {} CASCADE;", pg_schema(schema)),
4403 );
4404 }
4405 }
4406
4407 ops
4408}
4409
4410fn emit_create_table_from_db(t: &DbTable, ops: &mut Vec<DiffOp>) {
4411 let mut lines: Vec<String> = Vec::new();
4412 for col in &t.columns {
4413 let not_null = if col.nullable { "" } else { " NOT NULL" };
4414 if col.is_generated {
4415 lines.push(format!(
4417 " {} {} GENERATED ALWAYS AS (/* see source */) STORED",
4418 qi(&col.name),
4419 col.pg_type
4420 ));
4421 } else {
4422 lines.push(format!(" {} {}{}", qi(&col.name), col.pg_type, not_null));
4423 }
4424 }
4425 push_tx(
4426 ops,
4427 format!(
4428 "CREATE TABLE IF NOT EXISTS {}.{} (\n{}\n);",
4429 pg_schema(&t.schema),
4430 qi(&t.name),
4431 lines.join(",\n")
4432 ),
4433 );
4434}
4435
4436fn emit_column_diff_from_db(after: &DbTable, before: &DbTable, ops: &mut Vec<DiffOp>) {
4437 let before_cols: HashSet<&str> = before.columns.iter().map(|c| c.name.as_str()).collect();
4438 let after_cols: HashSet<&str> = after.columns.iter().map(|c| c.name.as_str()).collect();
4439
4440 for col in &after.columns {
4441 if !before_cols.contains(col.name.as_str()) {
4442 let not_null = if col.nullable { "" } else { " NOT NULL" };
4443 push_tx(
4444 ops,
4445 format!(
4446 "ALTER TABLE {}.{} ADD COLUMN IF NOT EXISTS {} {}{};",
4447 pg_schema(&after.schema),
4448 qi(&after.name),
4449 qi(&col.name),
4450 col.pg_type,
4451 not_null
4452 ),
4453 );
4454 }
4455 }
4456 for col in &before.columns {
4457 if !after_cols.contains(col.name.as_str()) {
4458 push_tx(
4459 ops,
4460 format!(
4461 "ALTER TABLE {}.{} DROP COLUMN IF EXISTS {};",
4462 pg_schema(&after.schema),
4463 qi(&after.name),
4464 qi(&col.name)
4465 ),
4466 );
4467 }
4468 }
4469}
4470
4471#[cfg(test)]
4474mod tests {
4475 use super::*;
4476 use crate::schema::{EnumDescriptor, LinkDescriptor, PropertyDescriptor, SchemaDescriptor, TypeDescriptor};
4477
4478 fn empty_state() -> DbState {
4479 DbState::default()
4480 }
4481
4482 fn prop(name: &str, pg_type: &str, nullable: bool) -> PropertyDescriptor {
4483 PropertyDescriptor {
4484 name: name.into(),
4485 pg_type: pg_type.into(),
4486 nullable,
4487 default_sql: if name == "id" { Some("uuidv7()".into()) } else { None },
4488 default_pyql: None,
4489 description: None,
4490 check_constraints: vec![],
4491 is_exclusive: name == "id",
4492 is_pk: name == "id",
4493 is_readonly: name == "id",
4494 rewrites: vec![],
4495 tuple_members: None,
4496 column_type: None,
4497 }
4498 }
4499
4500 fn simple_type(module: &str, name: &str, table: &str) -> TypeDescriptor {
4501 TypeDescriptor {
4502 name: name.into(),
4503 module: module.into(),
4504 table: table.into(),
4505 abstract_: false,
4506 materialized: false,
4507 description: None,
4508 parents: vec![],
4509 interfaces: vec![],
4510 bases: vec![],
4511 properties: vec![prop("id", "uuid", false), prop("name", "text", true)],
4512 links: vec![],
4513 multilinks: vec![],
4514 computed: vec![],
4515 constraints: vec![],
4516 indexes: vec![],
4517 partition: None,
4518 vector_indexes: vec![],
4519 search_indexes: vec![],
4520 triggers: vec![],
4521 junction: false,
4522 signals: vec![],
4523 }
4524 }
4525
4526 #[test]
4529 fn test_schema_content_changed_detects_a_readonly_only_flip() {
4530 let before = simple_type("default", "Person", "Person");
4534 let mut after = before.clone();
4535 after.properties[1].is_readonly = true; assert_ne!(before.properties[1].is_readonly, after.properties[1].is_readonly);
4537
4538 let schema_before = SchemaDescriptor {
4539 types: vec![before],
4540 scalars: vec![],
4541 enums: vec![],
4542 named_tuples: vec![],
4543 globals: vec![],
4544 functions: vec![],
4545 aliases: vec![],
4546 channels: vec![],
4547 ..Default::default()
4548 };
4549 let schema_after = SchemaDescriptor {
4550 types: vec![after],
4551 scalars: vec![],
4552 enums: vec![],
4553 named_tuples: vec![],
4554 globals: vec![],
4555 functions: vec![],
4556 aliases: vec![],
4557 channels: vec![],
4558 ..Default::default()
4559 };
4560 assert!(schema_content_changed(&schema_after, Some(&schema_before)));
4561 }
4562
4563 #[test]
4564 fn test_schema_content_changed_detects_a_new_rewrite() {
4565 let before = simple_type("default", "Person", "Person");
4566 let mut after = before.clone();
4567 after.properties[1].rewrites.push(crate::schema::RewriteEntry {
4568 on: 1,
4569 handler: "str_upper(.name)".into(),
4570 });
4571
4572 let schema_before = SchemaDescriptor {
4573 types: vec![before],
4574 scalars: vec![],
4575 enums: vec![],
4576 named_tuples: vec![],
4577 globals: vec![],
4578 functions: vec![],
4579 aliases: vec![],
4580 channels: vec![],
4581 ..Default::default()
4582 };
4583 let schema_after = SchemaDescriptor {
4584 types: vec![after],
4585 scalars: vec![],
4586 enums: vec![],
4587 named_tuples: vec![],
4588 globals: vec![],
4589 functions: vec![],
4590 aliases: vec![],
4591 channels: vec![],
4592 ..Default::default()
4593 };
4594 assert!(schema_content_changed(&schema_after, Some(&schema_before)));
4595 }
4596
4597 #[test]
4598 fn test_schema_content_changed_is_false_for_identical_schemas() {
4599 let t = simple_type("default", "Person", "Person");
4600 let schema = SchemaDescriptor {
4601 types: vec![t],
4602 scalars: vec![],
4603 enums: vec![],
4604 named_tuples: vec![],
4605 globals: vec![],
4606 functions: vec![],
4607 aliases: vec![],
4608 channels: vec![],
4609 ..Default::default()
4610 };
4611 let other = schema.clone();
4612 assert!(!schema_content_changed(&schema, Some(&other)));
4613 }
4614
4615 #[test]
4616 fn test_schema_content_changed_true_against_none_when_target_is_non_empty() {
4617 let t = simple_type("default", "Person", "Person");
4618 let schema = SchemaDescriptor {
4619 types: vec![t],
4620 scalars: vec![],
4621 enums: vec![],
4622 named_tuples: vec![],
4623 globals: vec![],
4624 functions: vec![],
4625 aliases: vec![],
4626 channels: vec![],
4627 ..Default::default()
4628 };
4629 assert!(
4630 schema_content_changed(&schema, None),
4631 "no prior snapshot at all must count as changed"
4632 );
4633 }
4634
4635 #[test]
4636 fn test_schema_content_changed_false_against_none_when_target_is_also_empty() {
4637 let schema = SchemaDescriptor::default();
4638 assert!(!schema_content_changed(&schema, None));
4639 }
4640
4641 #[test]
4642 fn test_schema_content_changed_still_true_when_ddl_visible_things_also_changed() {
4643 let schema_before = SchemaDescriptor::default();
4647 let schema_after = SchemaDescriptor {
4648 types: vec![simple_type("default", "Person", "Person")],
4649 scalars: vec![],
4650 enums: vec![],
4651 named_tuples: vec![],
4652 globals: vec![],
4653 functions: vec![],
4654 aliases: vec![],
4655 channels: vec![],
4656 ..Default::default()
4657 };
4658 assert!(schema_content_changed(&schema_after, Some(&schema_before)));
4659 }
4660
4661 #[test]
4662 fn test_schema_content_changed_detects_a_new_channel() {
4663 let schema_before = SchemaDescriptor::default();
4668 let schema_after = SchemaDescriptor {
4669 channels: vec![crate::schema::ChannelDescriptor {
4670 name: "UserUpdates".into(),
4671 module: "default".into(),
4672 wire_name: "default__user_updates".into(),
4673 payload: crate::schema::ChannelPayload::Scalar("text".into()),
4674 description: None,
4675 }],
4676 ..Default::default()
4677 };
4678 assert!(schema_content_changed(&schema_after, Some(&schema_before)));
4679 }
4680
4681 #[test]
4682 fn test_new_schema_and_table() {
4683 let schema = SchemaDescriptor {
4684 types: vec![simple_type("catalog", "Product", "Product")],
4685 scalars: vec![],
4686 enums: vec![],
4687 named_tuples: vec![],
4688 globals: vec![],
4689 functions: vec![],
4690 aliases: vec![],
4691 channels: vec![],
4692 ..Default::default()
4693 };
4694 let ops = diff_schema(&schema, &empty_state()).unwrap();
4695 let joined = ops.join("\n");
4696 assert!(
4697 joined.contains("CREATE SCHEMA IF NOT EXISTS \"catalog\""),
4698 "got:\n{joined}"
4699 );
4700 assert!(
4701 joined.contains("CREATE TABLE IF NOT EXISTS \"catalog\".\"Product\""),
4702 "got:\n{joined}"
4703 );
4704 assert!(
4705 joined.contains("CREATE OR REPLACE TRIGGER pylon_cache_invalidate\n AFTER INSERT OR UPDATE OR DELETE ON \"catalog\".\"Product\""),
4706 "new table must get the cache-invalidation trigger; got:\n{joined}"
4707 );
4708 }
4709
4710 fn widget_with_trigger(on: u8, timing: &str, handler: &str) -> TypeDescriptor {
4711 let mut t = simple_type("default", "Widget", "Widget");
4712 t.triggers = vec![crate::schema::TriggerDescriptor {
4713 on,
4714 timing: timing.into(),
4715 handler: handler.into(),
4716 }];
4717 t
4718 }
4719
4720 #[test]
4721 fn test_new_table_with_user_trigger_emits_the_compiled_trigger_ddl() {
4722 let schema = SchemaDescriptor {
4729 types: vec![widget_with_trigger(
4730 1,
4731 "After",
4732 "update Widget set { name := __new__.name }",
4733 )],
4734 scalars: vec![],
4735 enums: vec![],
4736 named_tuples: vec![],
4737 globals: vec![],
4738 functions: vec![],
4739 aliases: vec![],
4740 channels: vec![],
4741 ..Default::default()
4742 };
4743 let ops = diff_schema(&schema, &empty_state()).unwrap();
4744 let joined = ops.join("\n");
4745 assert!(joined.contains("NEW.\"name\""), "got:\n{joined}");
4746 }
4747
4748 #[test]
4749 fn a_trigger_emitted_by_an_older_build_is_replaced() {
4750 let schema = SchemaDescriptor {
4761 types: vec![widget_with_trigger(
4762 1,
4763 "After",
4764 "update Widget set { name := __new__.name }",
4765 )],
4766 scalars: vec![],
4767 enums: vec![],
4768 named_tuples: vec![],
4769 globals: vec![],
4770 functions: vec![],
4771 aliases: vec![],
4772 channels: vec![],
4773 ..Default::default()
4774 };
4775 let mut stale = schema_to_db_state(&schema);
4776 let current_name = stale
4777 .tables
4778 .iter()
4779 .flat_map(|t| t.triggers.iter().cloned())
4780 .find(|n| n.starts_with("Widget_"))
4781 .expect("the fixture should project a Widget trigger");
4782 let stale_name = "Widget_trg_0badc0de".to_string();
4783 for table in &mut stale.tables {
4784 for trigger in &mut table.triggers {
4785 if *trigger == current_name {
4786 *trigger = stale_name.clone();
4787 }
4788 }
4789 }
4790
4791 let joined = diff_schema(&schema, &stale).unwrap().join("\n");
4792 assert!(
4793 joined.contains(&format!("DROP TRIGGER IF EXISTS \"{stale_name}\"")),
4794 "the stale trigger should be dropped, got:\n{joined}"
4795 );
4796 assert!(
4797 joined.contains(¤t_name),
4798 "the current trigger should be created, got:\n{joined}"
4799 );
4800 assert!(
4803 joined.contains(&format!("DROP FUNCTION IF EXISTS \"public\".\"{stale_name}\"()")),
4804 "the orphaned function should be dropped, got:\n{joined}"
4805 );
4806 }
4807
4808 #[test]
4809 fn test_user_trigger_already_present_in_offline_baseline_produces_no_further_steps() {
4810 let schema = SchemaDescriptor {
4815 types: vec![widget_with_trigger(
4816 1,
4817 "After",
4818 "update Widget set { name := __new__.name }",
4819 )],
4820 scalars: vec![],
4821 enums: vec![],
4822 named_tuples: vec![],
4823 globals: vec![],
4824 functions: vec![],
4825 aliases: vec![],
4826 channels: vec![],
4827 ..Default::default()
4828 };
4829 let baseline = schema_to_db_state(&schema);
4830 let steps = diff_schema_steps(&schema, &baseline, &HashMap::new()).unwrap();
4831 assert!(
4832 steps.is_empty(),
4833 "expected zero further migration steps, got: {steps:?}"
4834 );
4835 }
4836
4837 #[test]
4838 fn a_link_target_gaining_a_subtype_loses_its_foreign_key() {
4839 let mut order = simple_type("default", "Order", "Order");
4840 order.links.push(LinkDescriptor {
4841 name: "customer".into(),
4842 target: "default::Person".into(),
4843 nullable: false,
4844 through: None,
4845 description: None,
4846 default_pyql: None,
4847 is_exclusive: false,
4848 is_readonly: false,
4849 rewrites: vec![],
4850 on_delete: vec![],
4851 });
4852 let before = SchemaDescriptor {
4853 types: vec![order, simple_type("default", "Person", "Person")],
4854 scalars: vec![],
4855 enums: vec![],
4856 named_tuples: vec![],
4857 globals: vec![],
4858 functions: vec![],
4859 aliases: vec![],
4860 channels: vec![],
4861 ..Default::default()
4862 };
4863 let mut after = before.clone();
4864 let mut vip = simple_type("default", "Vip", "Vip");
4865 vip.bases = vec!["default::Person".into()];
4866 after.types.push(vip);
4867
4868 let joined = diff_schema(&after, &schema_to_db_state(&before)).unwrap().join("\n");
4869 assert!(
4870 joined.contains("ALTER TABLE \"public\".\"Order\" DROP CONSTRAINT IF EXISTS \"Order_customer_fkey\""),
4871 "got:\n{joined}"
4872 );
4873 let settled = diff_schema_steps(&after, &schema_to_db_state(&after), &HashMap::new()).unwrap();
4874 assert!(settled.is_empty(), "nothing further once applied, got: {settled:?}");
4875 }
4876
4877 #[test]
4878 fn test_new_table_with_plain_link_gets_its_fk_constraint() {
4879 let mut order = simple_type("default", "Order", "Order");
4885 order.links.push(LinkDescriptor {
4886 name: "customer".into(),
4887 target: "default::Person".into(),
4888 nullable: false,
4889 through: None,
4890 description: None,
4891 default_pyql: None,
4892 is_exclusive: false,
4893 is_readonly: false,
4894 rewrites: vec![],
4895 on_delete: vec![],
4896 });
4897 let schema = SchemaDescriptor {
4898 types: vec![order, simple_type("default", "Person", "Person")],
4899 scalars: vec![],
4900 enums: vec![],
4901 named_tuples: vec![],
4902 globals: vec![],
4903 functions: vec![],
4904 aliases: vec![],
4905 channels: vec![],
4906 ..Default::default()
4907 };
4908 let ops = diff_schema(&schema, &empty_state()).unwrap();
4909 let joined = ops.join("\n");
4910 assert!(
4911 joined.contains("ADD CONSTRAINT \"Order_customer_fkey\" FOREIGN KEY (\"customer_id\") REFERENCES \"public\".\"Person\"(id)"),
4912 "new table's plain link must get its FK constraint in the same diff; got:\n{joined}"
4913 );
4914 }
4915
4916 #[test]
4917 fn test_an_existing_exclusive_multilink_junction_gains_its_unique_target() {
4918 use crate::schema::MultiLinkDescriptor;
4919
4920 let mut person = simple_type("default", "Person", "Person");
4921 person.multilinks.push(MultiLinkDescriptor {
4922 name: "keys".into(),
4923 target: "default::Key".into(),
4924 through: None,
4925 nullable: false,
4926 description: None,
4927 default_pyql: None,
4928 on_delete: vec![],
4929 is_exclusive: true,
4930 });
4931 let schema = SchemaDescriptor {
4932 types: vec![person, simple_type("default", "Key", "Key")],
4933 scalars: vec![],
4934 enums: vec![],
4935 named_tuples: vec![],
4936 globals: vec![],
4937 functions: vec![],
4938 aliases: vec![],
4939 channels: vec![],
4940 ..Default::default()
4941 };
4942 let mut state = schema_to_db_state(&schema);
4943 assert!(diff_schema(&schema, &state).unwrap().is_empty());
4944
4945 for table in state.tables.iter_mut().filter(|t| t.name == "Person.keys") {
4946 table.indexes.clear();
4947 }
4948 let joined = diff_schema(&schema, &state).unwrap().join("\n");
4949 assert!(
4950 joined.contains("CREATE UNIQUE INDEX IF NOT EXISTS \"Person.keys_target_key\" ON \"public\".\"Person.keys\" (\"target\")"),
4951 "got:\n{joined}"
4952 );
4953 }
4954
4955 #[test]
4956 fn test_cache_invalidate_trigger_not_duplicated_for_junction_through_type() {
4957 use crate::schema::MultiLinkDescriptor;
4958
4959 let mut product = simple_type("default", "Product", "Product");
4960 product.multilinks.push(MultiLinkDescriptor {
4961 name: "tags".into(),
4962 target: "default::Tag".into(),
4963 through: Some("default::ProductTag".into()),
4964 nullable: false,
4965 description: None,
4966 default_pyql: None,
4967 on_delete: vec![],
4968 is_exclusive: false,
4969 });
4970 let mut junction = simple_type("default", "ProductTag", "Product.tags");
4971 junction.junction = true;
4972
4973 let schema = SchemaDescriptor {
4974 types: vec![product, junction, simple_type("default", "Tag", "Tag")],
4975 scalars: vec![],
4976 enums: vec![],
4977 named_tuples: vec![],
4978 globals: vec![],
4979 functions: vec![],
4980 aliases: vec![],
4981 channels: vec![],
4982 ..Default::default()
4983 };
4984 let ops = diff_schema(&schema, &empty_state()).unwrap();
4985 let trigger_count = ops
4986 .iter()
4987 .filter(|op| op.contains("AFTER INSERT OR UPDATE OR DELETE ON \"public\".\"Product.tags\""))
4988 .count();
4989 assert_eq!(
4990 trigger_count, 1,
4991 "junction table's own td.table and the owning type's multilink both resolve to \
4992 the same physical table — must be deduped to one trigger, got {trigger_count} in: {ops:?}"
4993 );
4994 }
4995
4996 fn person_with_junction_backed_spouse() -> SchemaDescriptor {
4997 let mut person = simple_type("default", "Person", "Person");
4998 person.links.push(LinkDescriptor {
4999 name: "spouse".into(),
5000 target: "default::Person".into(),
5001 nullable: true,
5002 through: Some("default::Marriage".into()),
5003 description: None,
5004 default_pyql: None,
5005 is_exclusive: true,
5006 is_readonly: false,
5007 rewrites: vec![],
5008 on_delete: vec![],
5009 });
5010 let mut junction = simple_type("default", "Marriage", "Person.spouse");
5011 junction.junction = true;
5012
5013 SchemaDescriptor {
5014 types: vec![person, junction],
5015 scalars: vec![],
5016 enums: vec![],
5017 named_tuples: vec![],
5018 globals: vec![],
5019 functions: vec![],
5020 aliases: vec![],
5021 channels: vec![],
5022 ..Default::default()
5023 }
5024 }
5025
5026 #[test]
5027 fn test_junction_backed_single_link_creates_junction_table_from_scratch() {
5028 let schema = person_with_junction_backed_spouse();
5029 let ops = diff_schema(&schema, &empty_state()).unwrap();
5030 let joined = ops.join("\n");
5031 assert!(
5032 !joined.contains("spouse_id"),
5033 "no {{name}}_id column/FK for a junction-backed link, got:\n{joined}"
5034 );
5035 assert!(
5036 joined.contains("CREATE TABLE IF NOT EXISTS \"public\".\"Person.spouse\""),
5037 "got:\n{joined}"
5038 );
5039 assert!(joined.contains("PRIMARY KEY (source)"), "got:\n{joined}");
5040 assert!(
5041 joined.contains("CREATE UNIQUE INDEX IF NOT EXISTS \"Person.spouse_target_key\" ON \"public\".\"Person.spouse\" (\"target\")"),
5042 "got:\n{joined}"
5043 );
5044 }
5045
5046 #[test]
5047 fn test_junction_backed_single_link_diff_is_idempotent_once_applied() {
5048 let schema = person_with_junction_backed_spouse();
5056 let state = DbState {
5057 schemas: vec![],
5058 tables: vec![
5059 DbTable {
5060 schema: "default".into(),
5061 name: "Person".into(),
5062 columns: vec![
5063 DbColumn {
5064 name: "id".into(),
5065 pg_type: "uuid".into(),
5066 nullable: false,
5067 is_generated: false,
5068 column_default: Some("uuidv7()".into()),
5069 },
5070 DbColumn {
5071 name: "name".into(),
5072 pg_type: "text".into(),
5073 nullable: true,
5074 is_generated: false,
5075 column_default: None,
5076 },
5077 ],
5078 foreign_keys: vec![],
5079 indexes: vec![],
5080 checks: vec![],
5081 triggers: vec!["pylon_cache_invalidate".into()],
5082 },
5083 DbTable {
5084 schema: "default".into(),
5085 name: "Person.spouse".into(),
5086 columns: vec![
5087 DbColumn {
5088 name: "source".into(),
5089 pg_type: "uuid".into(),
5090 nullable: false,
5091 is_generated: false,
5092 column_default: None,
5093 },
5094 DbColumn {
5095 name: "target".into(),
5096 pg_type: "uuid".into(),
5097 nullable: false,
5098 is_generated: false,
5099 column_default: None,
5100 },
5101 DbColumn {
5102 name: "name".into(),
5103 pg_type: "text".into(),
5104 nullable: true,
5105 is_generated: false,
5106 column_default: None,
5107 },
5108 ],
5109 foreign_keys: vec![
5110 DbForeignKey {
5111 constraint_name: "Person_spouse_source_fkey".into(),
5112 local_column: "source".into(),
5113 ref_schema: "default".into(),
5114 ref_table: "Person".into(),
5115 },
5116 DbForeignKey {
5117 constraint_name: "Person_spouse_target_fkey".into(),
5118 local_column: "target".into(),
5119 ref_schema: "default".into(),
5120 ref_table: "Person".into(),
5121 },
5122 ],
5123 indexes: vec![DbIndex {
5124 name: "Person.spouse_target_key".into(),
5125 is_unique: true,
5126 method: "btree".into(),
5127 columns: vec!["target".into()],
5128 predicate: None,
5129 key: None,
5130 }],
5131 checks: vec![],
5132 triggers: vec!["pylon_cache_invalidate".into()],
5133 },
5134 ],
5135 enums: vec![],
5136 domains: vec![],
5137 ..DbState::default()
5138 };
5139 let ops = diff_schema(&schema, &state).unwrap();
5140 assert!(
5141 ops.is_empty(),
5142 "already-migrated junction-backed single link must diff to no ops, got: {:?}",
5143 ops
5144 );
5145 }
5146
5147 #[test]
5148 fn test_cache_invalidate_trigger_backfilled_on_pre_existing_table() {
5149 let schema = SchemaDescriptor {
5150 types: vec![simple_type("default", "Person", "Person")],
5151 scalars: vec![],
5152 enums: vec![],
5153 named_tuples: vec![],
5154 globals: vec![],
5155 functions: vec![],
5156 aliases: vec![],
5157 channels: vec![],
5158 ..Default::default()
5159 };
5160 let state = DbState {
5161 schemas: vec!["default".into()],
5162 tables: vec![DbTable {
5163 schema: "default".into(),
5164 name: "Person".into(),
5165 columns: vec![
5166 DbColumn {
5167 name: "id".into(),
5168 pg_type: "uuid".into(),
5169 nullable: false,
5170 is_generated: false,
5171 column_default: Some("uuidv7()".into()),
5172 },
5173 DbColumn {
5174 name: "name".into(),
5175 pg_type: "text".into(),
5176 nullable: true,
5177 is_generated: false,
5178 column_default: None,
5179 },
5180 ],
5181 foreign_keys: vec![],
5182 indexes: vec![],
5183 checks: vec![],
5184 triggers: vec![], }],
5186 enums: vec![],
5187 domains: vec![],
5188 ..DbState::default()
5189 };
5190 let ops = diff_schema(&schema, &state).unwrap();
5191 let joined = ops.join("\n");
5192 assert!(
5193 joined.contains("CREATE OR REPLACE TRIGGER pylon_cache_invalidate\n AFTER INSERT OR UPDATE OR DELETE ON \"public\".\"Person\""),
5194 "pre-existing table missing the trigger must get it backfilled; got:\n{joined}"
5195 );
5196 }
5197
5198 #[test]
5199 fn test_cache_invalidate_trigger_not_dropped_when_already_present() {
5200 let schema = SchemaDescriptor {
5201 types: vec![simple_type("default", "Person", "Person")],
5202 scalars: vec![],
5203 enums: vec![],
5204 named_tuples: vec![],
5205 globals: vec![],
5206 functions: vec![],
5207 aliases: vec![],
5208 channels: vec![],
5209 ..Default::default()
5210 };
5211 let state = DbState {
5212 schemas: vec!["default".into()],
5213 tables: vec![DbTable {
5214 schema: "default".into(),
5215 name: "Person".into(),
5216 columns: vec![
5217 DbColumn {
5218 name: "id".into(),
5219 pg_type: "uuid".into(),
5220 nullable: false,
5221 is_generated: false,
5222 column_default: Some("uuidv7()".into()),
5223 },
5224 DbColumn {
5225 name: "name".into(),
5226 pg_type: "text".into(),
5227 nullable: true,
5228 is_generated: false,
5229 column_default: None,
5230 },
5231 ],
5232 foreign_keys: vec![],
5233 indexes: vec![],
5234 checks: vec![],
5235 triggers: vec!["pylon_cache_invalidate".into()],
5236 }],
5237 enums: vec![],
5238 domains: vec![],
5239 ..DbState::default()
5240 };
5241 let ops = diff_schema(&schema, &state).unwrap();
5242 assert!(
5243 ops.iter()
5244 .all(|op| !op.contains("DROP TRIGGER") && !op.contains("pylon_cache_invalidate")),
5245 "already-present trigger must not be re-created or dropped; got: {:?}",
5246 ops
5247 );
5248 }
5249
5250 #[test]
5251 fn test_no_ops_when_in_sync() {
5252 let schema = SchemaDescriptor {
5253 types: vec![simple_type("default", "Person", "Person")],
5254 scalars: vec![],
5255 enums: vec![],
5256 named_tuples: vec![],
5257 globals: vec![],
5258 functions: vec![],
5259 aliases: vec![],
5260 channels: vec![],
5261 ..Default::default()
5262 };
5263 let state = DbState {
5264 schemas: vec!["default".into()],
5265 tables: vec![DbTable {
5266 schema: "default".into(),
5267 name: "Person".into(),
5268 columns: vec![
5269 DbColumn {
5270 name: "id".into(),
5271 pg_type: "uuid".into(),
5272 nullable: false,
5273 is_generated: false,
5274 column_default: Some("uuidv7()".into()),
5275 },
5276 DbColumn {
5277 name: "name".into(),
5278 pg_type: "text".into(),
5279 nullable: true,
5280 is_generated: false,
5281 column_default: None,
5282 },
5283 ],
5284 foreign_keys: vec![],
5285 indexes: vec![],
5286 checks: vec![],
5287 triggers: vec!["pylon_cache_invalidate".into()],
5288 }],
5289 enums: vec![],
5290 domains: vec![],
5291 ..DbState::default()
5292 };
5293 let ops = diff_schema(&schema, &state).unwrap();
5294 assert!(ops.is_empty(), "expected no ops, got: {:?}", ops);
5295 }
5296
5297 #[test]
5298 fn test_add_column() {
5299 let mut td = simple_type("default", "Person", "Person");
5300 td.properties.push(prop("email", "text", true));
5301 let schema = SchemaDescriptor {
5302 types: vec![td],
5303 scalars: vec![],
5304 enums: vec![],
5305 named_tuples: vec![],
5306 globals: vec![],
5307 functions: vec![],
5308 aliases: vec![],
5309 channels: vec![],
5310 ..Default::default()
5311 };
5312 let state = DbState {
5313 schemas: vec!["default".into()],
5314 tables: vec![DbTable {
5315 schema: "default".into(),
5316 name: "Person".into(),
5317 columns: vec![
5318 DbColumn {
5319 name: "id".into(),
5320 pg_type: "uuid".into(),
5321 nullable: false,
5322 is_generated: false,
5323 column_default: Some("uuidv7()".into()),
5324 },
5325 DbColumn {
5326 name: "name".into(),
5327 pg_type: "text".into(),
5328 nullable: true,
5329 is_generated: false,
5330 column_default: None,
5331 },
5332 ],
5333 foreign_keys: vec![],
5334 indexes: vec![],
5335 checks: vec![],
5336 triggers: vec![],
5337 }],
5338 enums: vec![],
5339 domains: vec![],
5340 ..DbState::default()
5341 };
5342 let ops = diff_schema(&schema, &state).unwrap();
5343 let joined = ops.join("\n");
5344 assert!(joined.contains("ADD COLUMN IF NOT EXISTS \"email\""), "got:\n{joined}");
5345 }
5346
5347 #[test]
5348 fn test_property_type_change_emits_alter_column_type() {
5349 let mut td = simple_type("default", "Person", "Person");
5352 td.properties.push(prop("rating", "int8", true));
5353 let schema = SchemaDescriptor {
5354 types: vec![td],
5355 scalars: vec![],
5356 enums: vec![],
5357 named_tuples: vec![],
5358 globals: vec![],
5359 functions: vec![],
5360 aliases: vec![],
5361 channels: vec![],
5362 ..Default::default()
5363 };
5364 let state = DbState {
5365 schemas: vec!["default".into()],
5366 tables: vec![DbTable {
5367 schema: "default".into(),
5368 name: "Person".into(),
5369 columns: vec![
5370 DbColumn {
5371 name: "id".into(),
5372 pg_type: "uuid".into(),
5373 nullable: false,
5374 is_generated: false,
5375 column_default: Some("uuidv7()".into()),
5376 },
5377 DbColumn {
5378 name: "name".into(),
5379 pg_type: "text".into(),
5380 nullable: true,
5381 is_generated: false,
5382 column_default: None,
5383 },
5384 DbColumn {
5385 name: "rating".into(),
5386 pg_type: "text".into(),
5387 nullable: true,
5388 is_generated: false,
5389 column_default: None,
5390 },
5391 ],
5392 foreign_keys: vec![],
5393 indexes: vec![],
5394 checks: vec![],
5395 triggers: vec![],
5396 }],
5397 enums: vec![],
5398 domains: vec![],
5399 ..DbState::default()
5400 };
5401 let ops = diff_schema(&schema, &state).unwrap();
5402 let joined = ops.join("\n");
5403 assert!(
5404 joined.contains(
5405 "ALTER TABLE \"public\".\"Person\" ALTER COLUMN \"rating\" TYPE int8 USING \"rating\"::int8;"
5406 ),
5407 "got:\n{joined}"
5408 );
5409 }
5410
5411 #[test]
5412 fn test_a_column_moving_between_enums_converts_through_text() {
5413 let mut td = simple_type("default", "Person", "Person");
5418 td.properties.push(prop("verb", "\"public\".\"HttpMethod\"", true));
5419 let schema = SchemaDescriptor {
5420 types: vec![td],
5421 enums: vec![crate::schema::EnumDescriptor {
5422 name: "HttpMethod".into(),
5423 module: "default".into(),
5424 members: vec!["GET".into(), "POST".into()],
5425 }],
5426 ..Default::default()
5427 };
5428 let state = DbState {
5429 schemas: vec!["default".into()],
5430 tables: vec![DbTable {
5431 schema: "default".into(),
5432 name: "Person".into(),
5433 columns: vec![
5434 DbColumn {
5435 name: "id".into(),
5436 pg_type: "uuid".into(),
5437 nullable: false,
5438 is_generated: false,
5439 column_default: Some("uuidv7()".into()),
5440 },
5441 DbColumn {
5442 name: "verb".into(),
5443 pg_type: "\"public\".\"Method\"".into(),
5444 nullable: true,
5445 is_generated: false,
5446 column_default: None,
5447 },
5448 ],
5449 foreign_keys: vec![],
5450 indexes: vec![],
5451 checks: vec![],
5452 triggers: vec![],
5453 }],
5454 enums: vec![],
5455 domains: vec![],
5456 ..DbState::default()
5457 };
5458 let joined = diff_schema(&schema, &state).unwrap().join("\n");
5459 assert!(
5460 joined.contains(r#"USING "verb"::text::"public"."HttpMethod";"#),
5461 "the conversion should go through text:\n{joined}"
5462 );
5463 }
5464
5465 #[test]
5466 fn test_a_column_default_is_dropped_before_its_type_changes() {
5467 let mut td = simple_type("default", "Person", "Person");
5471 let mut rating = prop("rating", "int8", true);
5472 rating.default_sql = Some("'1'".into());
5473 td.properties.push(rating);
5474 let schema = SchemaDescriptor {
5475 types: vec![td],
5476 ..Default::default()
5477 };
5478 let state = DbState {
5479 schemas: vec!["default".into()],
5480 tables: vec![DbTable {
5481 schema: "default".into(),
5482 name: "Person".into(),
5483 columns: vec![
5484 DbColumn {
5485 name: "id".into(),
5486 pg_type: "uuid".into(),
5487 nullable: false,
5488 is_generated: false,
5489 column_default: Some("uuidv7()".into()),
5490 },
5491 DbColumn {
5492 name: "rating".into(),
5493 pg_type: "text".into(),
5494 nullable: true,
5495 is_generated: false,
5496 column_default: Some("'x'::text".into()),
5497 },
5498 ],
5499 foreign_keys: vec![],
5500 indexes: vec![],
5501 checks: vec![],
5502 triggers: vec![],
5503 }],
5504 enums: vec![],
5505 domains: vec![],
5506 ..DbState::default()
5507 };
5508 let joined = diff_schema(&schema, &state).unwrap().join("\n");
5509 let Some(drop) = joined.find(r#"ALTER COLUMN "rating" DROP DEFAULT;"#) else {
5510 panic!("the default should be dropped:\n{joined}");
5511 };
5512 let Some(retype) = joined.find(r#"ALTER COLUMN "rating" TYPE int8"#) else {
5513 panic!("the type should change:\n{joined}");
5514 };
5515 assert!(drop < retype, "the drop must come first:\n{joined}");
5516 }
5517
5518 #[test]
5519 fn test_property_type_change_surfaces_a_required_cast_expression_step() {
5520 let mut td = simple_type("default", "Person", "Person");
5521 td.properties.push(prop("rating", "int8", true));
5522 let schema = SchemaDescriptor {
5523 types: vec![td],
5524 scalars: vec![],
5525 enums: vec![],
5526 named_tuples: vec![],
5527 globals: vec![],
5528 functions: vec![],
5529 aliases: vec![],
5530 channels: vec![],
5531 ..Default::default()
5532 };
5533 let state = DbState {
5534 schemas: vec!["default".into()],
5535 tables: vec![DbTable {
5536 schema: "default".into(),
5537 name: "Person".into(),
5538 columns: vec![
5539 DbColumn {
5540 name: "id".into(),
5541 pg_type: "uuid".into(),
5542 nullable: false,
5543 is_generated: false,
5544 column_default: Some("uuidv7()".into()),
5545 },
5546 DbColumn {
5547 name: "name".into(),
5548 pg_type: "text".into(),
5549 nullable: true,
5550 is_generated: false,
5551 column_default: None,
5552 },
5553 DbColumn {
5554 name: "rating".into(),
5555 pg_type: "text".into(),
5556 nullable: true,
5557 is_generated: false,
5558 column_default: None,
5559 },
5560 ],
5561 foreign_keys: vec![],
5562 indexes: vec![],
5563 checks: vec![],
5564 triggers: vec![],
5565 }],
5566 enums: vec![],
5567 domains: vec![],
5568 ..DbState::default()
5569 };
5570
5571 let steps = diff_schema_steps(&schema, &state, &HashMap::new()).unwrap();
5572 let step = steps
5573 .iter()
5574 .find(|s| matches!(&s.op_key, OpKey::Table(m, t) if m == "default" && t == "Person"))
5575 .expect("expected an alter step for Person");
5576
5577 assert_eq!(step.required_input.len(), 1, "got: {:?}", step.required_input);
5578 let input = &step.required_input[0];
5579 assert_eq!(input.placeholder, "cast_expr__rating");
5580 assert_eq!(input.default_expr, "\"rating\"::int8");
5581 assert_eq!(input.type_name, "default::Person");
5582
5583 let placeholder_token = format!("\\({})", input.placeholder);
5584 assert!(
5585 step.ddl.iter().any(|op| op.sql.contains(&placeholder_token)),
5586 "expected the placeholder token in the step's DDL, got: {:?}",
5587 step.ddl.iter().map(|op| &op.sql).collect::<Vec<_>>()
5588 );
5589 }
5590
5591 #[test]
5592 fn test_equivalent_base_type_spelling_is_not_a_diff() {
5593 let mut td = simple_type("default", "Person", "Person");
5599 td.properties.push(prop("age", "int8", true));
5600 let schema = SchemaDescriptor {
5601 types: vec![td],
5602 scalars: vec![],
5603 enums: vec![],
5604 named_tuples: vec![],
5605 globals: vec![],
5606 functions: vec![],
5607 aliases: vec![],
5608 channels: vec![],
5609 ..Default::default()
5610 };
5611 let state = DbState {
5612 schemas: vec!["default".into()],
5613 tables: vec![DbTable {
5614 schema: "default".into(),
5615 name: "Person".into(),
5616 columns: vec![
5617 DbColumn {
5618 name: "id".into(),
5619 pg_type: "uuid".into(),
5620 nullable: false,
5621 is_generated: false,
5622 column_default: Some("uuidv7()".into()),
5623 },
5624 DbColumn {
5625 name: "name".into(),
5626 pg_type: "text".into(),
5627 nullable: true,
5628 is_generated: false,
5629 column_default: None,
5630 },
5631 DbColumn {
5632 name: "age".into(),
5633 pg_type: "bigint".into(),
5634 nullable: true,
5635 is_generated: false,
5636 column_default: None,
5637 },
5638 ],
5639 foreign_keys: vec![],
5640 indexes: vec![],
5641 checks: vec![],
5642 triggers: vec!["pylon_cache_invalidate".into()],
5643 }],
5644 enums: vec![],
5645 domains: vec![],
5646 ..DbState::default()
5647 };
5648 let ops = diff_schema(&schema, &state).unwrap();
5649 assert!(
5650 ops.iter().all(|op| !op.contains("ALTER COLUMN")),
5651 "expected no ALTER COLUMN ops, got: {:?}",
5652 ops
5653 );
5654 }
5655
5656 #[test]
5657 fn test_check_the_schema_no_longer_declares_is_dropped() {
5658 let mut person = simple_type("default", "Person", "Person");
5661 person.properties = vec![prop("id", "uuid", false)];
5662 let schema = SchemaDescriptor {
5663 types: vec![person],
5664 ..SchemaDescriptor::default()
5665 };
5666 let state = DbState {
5667 tables: vec![DbTable {
5668 schema: "default".into(),
5669 name: "Person".into(),
5670 columns: vec![DbColumn {
5671 name: "id".into(),
5672 pg_type: "uuid".into(),
5673 nullable: false,
5674 is_generated: false,
5675 column_default: None,
5676 }],
5677 foreign_keys: vec![],
5678 indexes: vec![],
5679 checks: vec![
5680 DbCheck {
5681 constraint_name: "Person_name_deadbeef_check".into(),
5682 },
5683 DbCheck {
5684 constraint_name: "a_hand_written_check".into(),
5685 },
5686 ],
5687 triggers: vec![],
5688 }],
5689 ..DbState::default()
5690 };
5691 let ops = diff_schema_ops(&schema, &state).unwrap();
5692 let joined = ops.iter().map(|op| op.sql.as_str()).collect::<Vec<_>>().join("\n");
5693 assert!(
5694 joined.contains("DROP CONSTRAINT IF EXISTS \"Person_name_deadbeef_check\""),
5695 "got:\n{joined}"
5696 );
5697 assert!(
5698 !joined.contains("a_hand_written_check"),
5699 "a constraint Pylon did not name is left alone:\n{joined}"
5700 );
5701 }
5702
5703 #[test]
5704 fn test_changed_scalar_domain_checks_are_reconciled() {
5705 use crate::schema::ScalarDescriptor;
5706 let schema = SchemaDescriptor {
5707 scalars: vec![ScalarDescriptor {
5708 name: "EmailStr".into(),
5709 module: "default".into(),
5710 base: "Str".into(),
5711 pg_type: "text".into(),
5712 check_constraints: vec!["char_length(VALUE) <= 320".into()],
5713 is_sequence: false,
5714 }],
5715 ..SchemaDescriptor::default()
5716 };
5717 let state = DbState {
5718 domains: vec![DbDomain {
5719 schema: "default".into(),
5720 name: "EmailStr".into(),
5721 checks: vec!["EmailStr_stale000_check".into()],
5722 }],
5723 ..DbState::default()
5724 };
5725 let ops = diff_schema_ops(&schema, &state).unwrap();
5726 let joined = ops.iter().map(|op| op.sql.as_str()).collect::<Vec<_>>().join("\n");
5727 assert!(joined.contains("DROP CONSTRAINT"), "the old set is cleared:\n{joined}");
5728 assert!(
5729 joined.contains("ADD CONSTRAINT") && joined.contains("char_length(VALUE) <= 320"),
5730 "the new set is added:\n{joined}"
5731 );
5732 }
5733
5734 #[test]
5735 fn test_demoting_an_interface_to_a_mixin_drops_its_view() {
5736 let mut individual = simple_type("default", "Individual", "Individual");
5739 individual.properties = vec![prop("id", "uuid", false)];
5740 let schema = SchemaDescriptor {
5741 types: vec![individual],
5742 ..SchemaDescriptor::default()
5743 };
5744 let state = DbState {
5745 tables: vec![],
5746 views: vec![DbView {
5747 schema: "public".into(),
5748 name: "Account".into(),
5749 body_hash: "whatever".into(),
5750 }],
5751 ..DbState::default()
5752 };
5753 let ops = diff_schema_ops(&schema, &state).unwrap();
5754 let joined = ops.iter().map(|op| op.sql.as_str()).collect::<Vec<_>>().join("\n");
5755 assert!(
5756 joined.contains("DROP VIEW IF EXISTS \"public\".\"Account\""),
5757 "got:\n{joined}"
5758 );
5759 }
5760
5761 #[test]
5762 fn test_registered_scalar_domain_adoption_drops_and_recreates_dependent_interface_view() {
5763 use crate::schema::ScalarDescriptor;
5769
5770 let mut account = simple_type("default", "Account", "Account");
5771 account.abstract_ = true;
5772 account.materialized = true;
5773 account.properties = vec![prop("id", "uuid", false), prop("email", "text", false)];
5774 account.properties[1].column_type = Some("\"public\".\"Email\"".into());
5775
5776 let mut individual = simple_type("default", "Individual", "Individual");
5777 individual.interfaces = vec!["default::Account".into()];
5778 individual.properties = vec![prop("id", "uuid", false), prop("email", "text", false)];
5779 individual.properties[1].column_type = Some("\"public\".\"Email\"".into());
5780
5781 let schema = SchemaDescriptor {
5782 types: vec![account, individual],
5783 scalars: vec![ScalarDescriptor {
5784 name: "Email".into(),
5785 module: "default".into(),
5786 base: "Str".into(),
5787 pg_type: "text".into(),
5788 check_constraints: vec!["value ~ '@'".into()],
5789 is_sequence: false,
5790 }],
5791 enums: vec![],
5792 named_tuples: vec![],
5793 globals: vec![],
5794 functions: vec![],
5795 aliases: vec![],
5796 channels: vec![],
5797 ..Default::default()
5798 };
5799
5800 let view_ddl = crate::export::interface_view_ddl_with_names(&schema)
5805 .into_iter()
5806 .find(|(_, n, _)| n == "Account")
5807 .unwrap()
5808 .2;
5809
5810 let state = DbState {
5811 schemas: vec!["default".into()],
5812 tables: vec![DbTable {
5813 schema: "default".into(),
5814 name: "Individual".into(),
5815 columns: vec![
5816 DbColumn {
5817 name: "id".into(),
5818 pg_type: "uuid".into(),
5819 nullable: false,
5820 is_generated: false,
5821 column_default: Some("uuidv7()".into()),
5822 },
5823 DbColumn {
5824 name: "email".into(),
5825 pg_type: "text".into(),
5826 nullable: false,
5827 is_generated: false,
5828 column_default: None,
5829 },
5830 ],
5831 foreign_keys: vec![],
5832 indexes: vec![],
5833 checks: vec![],
5834 triggers: vec!["pylon_cache_invalidate".into()],
5835 }],
5836 views: vec![DbView {
5837 schema: "default".into(),
5838 name: "Account".into(),
5839 body_hash: ddl_hash(&view_ddl),
5840 }],
5841 enums: vec![],
5842 domains: vec![],
5843 ..DbState::default()
5844 };
5845
5846 let ops = diff_schema_ops(&schema, &state).unwrap();
5850 let joined = ops.iter().map(|op| op.sql.as_str()).collect::<Vec<_>>().join("\n");
5851 let drop_pos = joined
5852 .find("DROP VIEW IF EXISTS \"public\".\"Account\"")
5853 .unwrap_or_else(|| panic!("missing DROP VIEW; got:\n{joined}"));
5854 let alter_pos = joined
5855 .find("ALTER TABLE \"public\".\"Individual\" ALTER COLUMN \"email\" TYPE \"public\".\"Email\"")
5856 .unwrap_or_else(|| panic!("missing ALTER COLUMN TYPE; got:\n{joined}"));
5857 let create_pos = joined
5858 .rfind("CREATE VIEW \"public\".\"Account\"")
5859 .unwrap_or_else(|| panic!("missing CREATE VIEW; got:\n{joined}"));
5860 assert!(drop_pos < alter_pos, "DROP VIEW must precede the ALTER; got:\n{joined}");
5861 assert!(
5862 alter_pos < create_pos,
5863 "CREATE VIEW must follow the ALTER; got:\n{joined}"
5864 );
5865 assert_eq!(
5866 joined.matches("CREATE VIEW \"public\".\"Account\"").count(),
5867 1,
5868 "view must be recreated exactly once, not duplicated by Phase 10; got:\n{joined}"
5869 );
5870 }
5871
5872 fn exclusive_email_account_schema(implementor_names: &[&str]) -> SchemaDescriptor {
5873 let mut account = simple_type("default", "Account", "Account");
5874 account.abstract_ = true;
5875 account.materialized = true;
5876 account.properties = vec![prop("id", "uuid", false), prop("email", "text", false)];
5877 account.properties[1].is_exclusive = true;
5878
5879 let mut types = vec![account];
5880 for name in implementor_names {
5881 let mut t = simple_type("default", name, name);
5882 t.interfaces = vec!["default::Account".into()];
5883 t.properties = vec![prop("id", "uuid", false), prop("email", "text", false)];
5884 t.properties[1].is_exclusive = true;
5885 types.push(t);
5886 }
5887 SchemaDescriptor {
5888 types,
5889 scalars: vec![],
5890 enums: vec![],
5891 named_tuples: vec![],
5892 globals: vec![],
5893 functions: vec![],
5894 aliases: vec![],
5895 channels: vec![],
5896 ..Default::default()
5897 }
5898 }
5899
5900 #[test]
5901 fn test_new_implementor_added_to_existing_interface_gets_exclusive_triggers_retroactively() {
5902 let schema = exclusive_email_account_schema(&["Individual", "Organization"]);
5908 let state = DbState {
5909 schemas: vec!["default".into()],
5910 tables: vec![DbTable {
5911 schema: "default".into(),
5912 name: "Individual".into(),
5913 columns: vec![
5914 DbColumn {
5915 name: "id".into(),
5916 pg_type: "uuid".into(),
5917 nullable: false,
5918 is_generated: false,
5919 column_default: Some("uuidv7()".into()),
5920 },
5921 DbColumn {
5922 name: "email".into(),
5923 pg_type: "text".into(),
5924 nullable: false,
5925 is_generated: false,
5926 column_default: None,
5927 },
5928 ],
5929 foreign_keys: vec![],
5930 indexes: vec![],
5931 checks: vec![],
5932 triggers: vec![
5933 "pylon_cache_invalidate".into(),
5934 "_excl_Account_email_ins".into(),
5935 "_excl_Account_email_upd".into(),
5936 ],
5937 }],
5938 enums: vec![],
5939 domains: vec![],
5940 ..DbState::default()
5941 };
5942
5943 let ops = diff_schema_ops(&schema, &state).unwrap();
5944 let joined = ops.iter().map(|op| op.sql.as_str()).collect::<Vec<_>>().join("\n");
5945 assert!(
5946 joined.contains(
5947 "CREATE CONSTRAINT TRIGGER \"_excl_Account_email_ins\"\nAFTER INSERT ON \"public\".\"Organization\""
5948 ),
5949 "the new implementor must get the exclusive trigger; got:\n{joined}"
5950 );
5951 assert!(
5952 !joined.contains("ON \"public\".\"Individual\""),
5953 "the already-migrated implementor's existing triggers must not be re-emitted; got:\n{joined}"
5954 );
5955 }
5956
5957 #[test]
5958 fn test_the_shared_exclusive_guard_function_precedes_every_implementors_trigger() {
5959 let mut schema = exclusive_email_account_schema(&["Individual", "Organization"]);
5965 let individual = schema
5966 .types
5967 .iter_mut()
5968 .find(|t| t.name == "Individual")
5969 .expect("Individual is in the schema");
5970 individual.links.push(LinkDescriptor {
5971 name: "employer".into(),
5972 target: "default::Organization".into(),
5973 nullable: false,
5974 through: None,
5975 description: None,
5976 default_pyql: None,
5977 is_exclusive: false,
5978 is_readonly: false,
5979 rewrites: vec![],
5980 on_delete: vec![],
5981 });
5982
5983 let ops = diff_schema_ops(&schema, &DbState::default()).unwrap();
5984 let joined = ops.iter().map(|op| op.sql.as_str()).collect::<Vec<_>>().join("\n");
5985 let function_pos = joined
5986 .find("CREATE OR REPLACE FUNCTION \"public\".\"_excl_Account_email\"")
5987 .unwrap_or_else(|| panic!("missing the guard function; got:\n{joined}"));
5988 let first_trigger_pos = joined
5989 .find("CREATE CONSTRAINT TRIGGER \"_excl_Account_email_ins\"")
5990 .unwrap_or_else(|| panic!("missing the exclusive triggers; got:\n{joined}"));
5991 assert!(
5992 function_pos < first_trigger_pos,
5993 "the guard function must be defined before the first trigger naming it; got:\n{joined}"
5994 );
5995 assert_eq!(
5996 joined
5997 .matches("CREATE OR REPLACE FUNCTION \"public\".\"_excl_Account_email\"")
5998 .count(),
5999 1,
6000 "the guard function must be emitted exactly once; got:\n{joined}"
6001 );
6002 }
6003
6004 #[test]
6005 fn test_removing_exclusivity_drops_the_cross_table_triggers() {
6006 let mut schema = exclusive_email_account_schema(&["Individual"]);
6011 for t in &mut schema.types {
6012 for p in &mut t.properties {
6013 if p.name == "email" {
6014 p.is_exclusive = false;
6015 }
6016 }
6017 }
6018 let state = DbState {
6019 schemas: vec!["default".into()],
6020 tables: vec![DbTable {
6021 schema: "default".into(),
6022 name: "Individual".into(),
6023 columns: vec![
6024 DbColumn {
6025 name: "id".into(),
6026 pg_type: "uuid".into(),
6027 nullable: false,
6028 is_generated: false,
6029 column_default: Some("uuidv7()".into()),
6030 },
6031 DbColumn {
6032 name: "email".into(),
6033 pg_type: "text".into(),
6034 nullable: false,
6035 is_generated: false,
6036 column_default: None,
6037 },
6038 ],
6039 foreign_keys: vec![],
6040 indexes: vec![],
6041 checks: vec![],
6042 triggers: vec![
6043 "pylon_cache_invalidate".into(),
6044 "_excl_Account_email_ins".into(),
6045 "_excl_Account_email_upd".into(),
6046 ],
6047 }],
6048 enums: vec![],
6049 domains: vec![],
6050 ..DbState::default()
6051 };
6052
6053 let ops = diff_schema_ops(&schema, &state).unwrap();
6054 let joined = ops.iter().map(|op| op.sql.as_str()).collect::<Vec<_>>().join("\n");
6055 assert!(
6056 joined.contains("DROP TRIGGER IF EXISTS \"_excl_Account_email_ins\" ON \"public\".\"Individual\""),
6057 "got:\n{joined}"
6058 );
6059 assert!(
6060 joined.contains("DROP TRIGGER IF EXISTS \"_excl_Account_email_upd\" ON \"public\".\"Individual\""),
6061 "got:\n{joined}"
6062 );
6063 assert!(
6064 !joined.contains("DROP TRIGGER IF EXISTS \"pylon_cache_invalidate\""),
6065 "unrelated triggers must not be touched; got:\n{joined}"
6066 );
6067 }
6068
6069 #[test]
6070 fn test_new_enum() {
6071 let schema = SchemaDescriptor {
6072 types: vec![],
6073 scalars: vec![],
6074 enums: vec![EnumDescriptor {
6075 name: "Status".into(),
6076 module: "default".into(),
6077 members: vec!["Active".into(), "Inactive".into()],
6078 }],
6079 named_tuples: vec![],
6080 globals: vec![],
6081 functions: vec![],
6082 aliases: vec![],
6083 channels: vec![],
6084 ..Default::default()
6085 };
6086 let ops = diff_schema(&schema, &empty_state()).unwrap();
6087 let joined = ops.join("\n");
6088 assert!(
6089 joined.contains("CREATE TYPE \"public\".\"Status\" AS ENUM"),
6090 "got:\n{joined}"
6091 );
6092 }
6093
6094 #[test]
6095 fn test_drop_table() {
6096 let schema = SchemaDescriptor {
6097 types: vec![],
6098 scalars: vec![],
6099 enums: vec![],
6100 named_tuples: vec![],
6101 globals: vec![],
6102 functions: vec![],
6103 aliases: vec![],
6104 channels: vec![],
6105 ..Default::default()
6106 };
6107 let state = DbState {
6108 schemas: vec!["default".into()],
6109 tables: vec![DbTable {
6110 schema: "default".into(),
6111 name: "OldType".into(),
6112 columns: vec![],
6113 foreign_keys: vec![],
6114 indexes: vec![],
6115 checks: vec![],
6116 triggers: vec![],
6117 }],
6118 enums: vec![],
6119 domains: vec![],
6120 ..DbState::default()
6121 };
6122 let ops = diff_schema(&schema, &state).unwrap();
6123 let joined = ops.join("\n");
6124 assert!(
6125 joined.contains("DROP TABLE IF EXISTS \"public\".\"OldType\" CASCADE"),
6126 "got:\n{joined}"
6127 );
6128 }
6129
6130 #[test]
6131 fn test_index_on_existing_table_is_concurrently() {
6132 use crate::schema::VectorIndexDescriptor;
6133 let mut td = simple_type("default", "Post", "Post");
6134 td.vector_indexes.push(VectorIndexDescriptor {
6135 index_name: None,
6136 pointers: vec!["name".into()],
6137 model: "test".into(),
6138 metric: "cosine".into(),
6139 dimensions: 1536,
6140 });
6141 let schema = SchemaDescriptor {
6142 types: vec![td],
6143 scalars: vec![],
6144 enums: vec![],
6145 named_tuples: vec![],
6146 globals: vec![],
6147 functions: vec![],
6148 aliases: vec![],
6149 channels: vec![],
6150 ..Default::default()
6151 };
6152 let state = DbState {
6154 schemas: vec!["default".into()],
6155 tables: vec![DbTable {
6156 schema: "default".into(),
6157 name: "Post".into(),
6158 columns: vec![
6159 DbColumn {
6160 name: "id".into(),
6161 pg_type: "uuid".into(),
6162 nullable: false,
6163 is_generated: false,
6164 column_default: Some("uuidv7()".into()),
6165 },
6166 DbColumn {
6167 name: "name".into(),
6168 pg_type: "text".into(),
6169 nullable: true,
6170 is_generated: false,
6171 column_default: None,
6172 },
6173 ],
6174 foreign_keys: vec![],
6175 indexes: vec![],
6176 checks: vec![],
6177 triggers: vec![],
6178 }],
6179 enums: vec![],
6180 domains: vec![],
6181 ..DbState::default()
6182 };
6183 let ops = diff_schema_ops(&schema, &state).unwrap();
6184 let idx_op = ops.iter().find(|op| op.sql.contains("hnsw")).unwrap();
6185 assert!(
6186 idx_op.non_transactional,
6187 "index on pre-existing table should be non-transactional"
6188 );
6189 assert!(
6190 idx_op.sql.contains("CONCURRENTLY"),
6191 "should use CONCURRENTLY: {}",
6192 idx_op.sql
6193 );
6194 }
6195
6196 #[test]
6197 fn test_required_extensions_empty_without_vector_indexes() {
6198 let schema = SchemaDescriptor {
6199 types: vec![simple_type("default", "Post", "Post")],
6200 scalars: vec![],
6201 enums: vec![],
6202 named_tuples: vec![],
6203 globals: vec![],
6204 functions: vec![],
6205 aliases: vec![],
6206 channels: vec![],
6207 ..Default::default()
6208 };
6209 assert!(required_extensions(&schema).is_empty());
6210 }
6211
6212 #[test]
6213 fn test_missing_extension_ddl_when_vector_index_present_and_not_yet_installed() {
6214 use crate::schema::VectorIndexDescriptor;
6215 let mut td = simple_type("default", "Post", "Post");
6216 td.vector_indexes.push(VectorIndexDescriptor {
6217 index_name: None,
6218 pointers: vec!["name".into()],
6219 model: "test".into(),
6220 metric: "cosine".into(),
6221 dimensions: 1536,
6222 });
6223 let schema = SchemaDescriptor {
6224 types: vec![td],
6225 scalars: vec![],
6226 enums: vec![],
6227 named_tuples: vec![],
6228 globals: vec![],
6229 functions: vec![],
6230 aliases: vec![],
6231 channels: vec![],
6232 ..Default::default()
6233 };
6234 assert_eq!(required_extensions(&schema), vec!["vector"]);
6235
6236 let ddl = missing_extension_ddl(&schema, &DbState::default());
6237 assert_eq!(ddl, vec!["CREATE EXTENSION IF NOT EXISTS \"vector\";".to_string()]);
6238
6239 let already_installed = DbState {
6240 extensions: vec!["vector".into()],
6241 ..DbState::default()
6242 };
6243 assert!(missing_extension_ddl(&schema, &already_installed).is_empty());
6244 }
6245
6246 #[test]
6247 fn test_index_on_new_table_is_transactional() {
6248 use crate::schema::VectorIndexDescriptor;
6249 let mut td = simple_type("default", "Post", "Post");
6250 td.vector_indexes.push(VectorIndexDescriptor {
6251 index_name: None,
6252 pointers: vec!["name".into()],
6253 model: "test".into(),
6254 metric: "cosine".into(),
6255 dimensions: 1536,
6256 });
6257 let schema = SchemaDescriptor {
6258 types: vec![td],
6259 scalars: vec![],
6260 enums: vec![],
6261 named_tuples: vec![],
6262 globals: vec![],
6263 functions: vec![],
6264 aliases: vec![],
6265 channels: vec![],
6266 ..Default::default()
6267 };
6268 let ops = diff_schema_ops(&schema, &empty_state()).unwrap();
6270 let idx_op = ops.iter().find(|op| op.sql.contains("hnsw")).unwrap();
6271 assert!(!idx_op.non_transactional, "index on new table should be transactional");
6272 assert!(
6273 !idx_op.sql.contains("CONCURRENTLY"),
6274 "should NOT use CONCURRENTLY: {}",
6275 idx_op.sql
6276 );
6277 }
6278
6279 fn sequence_scalar(module: &str, name: &str) -> crate::schema::ScalarDescriptor {
6280 crate::schema::ScalarDescriptor {
6281 name: name.into(),
6282 module: module.into(),
6283 base: "Sequence".into(),
6284 pg_type: "int8".into(),
6285 check_constraints: vec![],
6286 is_sequence: true,
6287 }
6288 }
6289
6290 #[test]
6291 fn test_new_sequence_creates_sequence_and_domain() {
6292 let schema = SchemaDescriptor {
6293 types: vec![],
6294 enums: vec![],
6295 named_tuples: vec![],
6296 globals: vec![],
6297 functions: vec![],
6298 aliases: vec![],
6299 channels: vec![],
6300 scalars: vec![sequence_scalar("default", "OrderNumber")],
6301 ..Default::default()
6302 };
6303 let ops = diff_schema(&schema, &empty_state()).unwrap();
6304 let joined = ops.join("\n");
6305 assert!(
6306 joined.contains("CREATE SEQUENCE IF NOT EXISTS \"public\".\"OrderNumber_seq\""),
6307 "got:\n{joined}"
6308 );
6309 assert!(
6310 joined.contains("CREATE DOMAIN \"public\".\"OrderNumber\" AS int8"),
6311 "got:\n{joined}"
6312 );
6313 let seq_pos = joined.find("CREATE SEQUENCE").unwrap();
6315 let dom_pos = joined.find("CREATE DOMAIN").unwrap();
6316 assert!(seq_pos < dom_pos, "sequence must be created before domain");
6317 }
6318
6319 #[test]
6320 fn test_no_ops_sequence_already_exists() {
6321 let schema = SchemaDescriptor {
6322 types: vec![],
6323 enums: vec![],
6324 named_tuples: vec![],
6325 globals: vec![],
6326 functions: vec![],
6327 aliases: vec![],
6328 channels: vec![],
6329 scalars: vec![sequence_scalar("default", "OrderNumber")],
6330 ..Default::default()
6331 };
6332 let state = DbState {
6333 schemas: vec!["default".into()],
6334 domains: vec![DbDomain {
6335 schema: "default".into(),
6336 name: "OrderNumber".into(),
6337 checks: vec![],
6338 }],
6339 sequences: vec![DbSequence {
6340 schema: "default".into(),
6341 name: "OrderNumber_seq".into(),
6342 }],
6343 ..DbState::default()
6344 };
6345 let ops = diff_schema(&schema, &state).unwrap();
6346 assert!(
6347 ops.is_empty(),
6348 "expected no ops when sequence and domain exist, got: {:?}",
6349 ops
6350 );
6351 }
6352
6353 #[test]
6354 fn test_drop_removed_sequence() {
6355 let schema = SchemaDescriptor {
6356 types: vec![],
6357 scalars: vec![],
6358 enums: vec![],
6359 named_tuples: vec![],
6360 globals: vec![],
6361 functions: vec![],
6362 aliases: vec![],
6363 channels: vec![],
6364 ..Default::default()
6365 };
6366 let state = DbState {
6367 schemas: vec!["default".into()],
6368 domains: vec![DbDomain {
6369 schema: "default".into(),
6370 name: "OrderNumber".into(),
6371 checks: vec![],
6372 }],
6373 sequences: vec![DbSequence {
6374 schema: "default".into(),
6375 name: "OrderNumber_seq".into(),
6376 }],
6377 ..DbState::default()
6378 };
6379 let ops = diff_schema(&schema, &state).unwrap();
6380 let joined = ops.join("\n");
6381 assert!(
6382 joined.contains("DROP DOMAIN IF EXISTS \"public\".\"OrderNumber\""),
6383 "got:\n{joined}"
6384 );
6385 assert!(
6386 joined.contains("DROP SEQUENCE IF EXISTS \"public\".\"OrderNumber_seq\""),
6387 "got:\n{joined}"
6388 );
6389 }
6390
6391 #[test]
6394 fn test_diff_schema_steps_groups_multiple_column_changes_into_one_alter_step() {
6395 let mut person = simple_type("default", "Person", "Person");
6396 person.properties.push(prop("nickname", "text", true));
6397 person.properties.push(prop("age", "int8", true));
6398 let schema = SchemaDescriptor {
6399 types: vec![person],
6400 scalars: vec![],
6401 enums: vec![],
6402 named_tuples: vec![],
6403 globals: vec![],
6404 functions: vec![],
6405 aliases: vec![],
6406 channels: vec![],
6407 ..Default::default()
6408 };
6409 let state = DbState {
6410 schemas: vec!["default".into()],
6411 tables: vec![DbTable {
6412 schema: "default".into(),
6413 name: "Person".into(),
6414 columns: vec![
6415 DbColumn {
6416 name: "id".into(),
6417 pg_type: "uuid".into(),
6418 nullable: false,
6419 is_generated: false,
6420 column_default: Some("uuidv7()".into()),
6421 },
6422 DbColumn {
6423 name: "name".into(),
6424 pg_type: "text".into(),
6425 nullable: true,
6426 is_generated: false,
6427 column_default: None,
6428 },
6429 ],
6430 foreign_keys: vec![],
6431 indexes: vec![],
6432 checks: vec![],
6433 triggers: vec!["pylon_cache_invalidate".into()],
6434 }],
6435 enums: vec![],
6436 domains: vec![],
6437 ..DbState::default()
6438 };
6439
6440 let steps = diff_schema_steps(&schema, &state, &HashMap::new()).unwrap();
6441 let table_steps: Vec<&MigrationStep> = steps
6442 .iter()
6443 .filter(|s| matches!(&s.op_key, OpKey::Table(m, t) if m == "default" && t == "Person"))
6444 .collect();
6445 assert_eq!(
6446 table_steps.len(),
6447 1,
6448 "two new columns on the same table must produce one step, got: {:?}",
6449 steps.iter().map(|s| &s.prompt).collect::<Vec<_>>()
6450 );
6451 assert_eq!(table_steps[0].verb, Verb::Alter);
6452 assert_eq!(table_steps[0].prompt, "did you alter object type 'default::Person'?");
6453 assert_eq!(
6454 table_steps[0].ddl.len(),
6455 2,
6456 "expected one ADD COLUMN per new property, got: {:?}",
6457 table_steps[0].ddl.iter().map(|d| &d.sql).collect::<Vec<_>>()
6458 );
6459 }
6460
6461 #[test]
6462 fn test_diff_schema_steps_new_table_is_one_create_step_including_its_trigger() {
6463 let schema = SchemaDescriptor {
6464 types: vec![simple_type("catalog", "Product", "Product")],
6465 scalars: vec![],
6466 enums: vec![],
6467 named_tuples: vec![],
6468 globals: vec![],
6469 functions: vec![],
6470 aliases: vec![],
6471 channels: vec![],
6472 ..Default::default()
6473 };
6474 let steps = diff_schema_steps(&schema, &empty_state(), &HashMap::new()).unwrap();
6475 let table_steps: Vec<&MigrationStep> = steps
6476 .iter()
6477 .filter(|s| matches!(&s.op_key, OpKey::Table(m, t) if m == "catalog" && t == "Product"))
6478 .collect();
6479 assert_eq!(
6480 table_steps.len(),
6481 1,
6482 "got steps: {:?}",
6483 steps.iter().map(|s| &s.prompt).collect::<Vec<_>>()
6484 );
6485 assert_eq!(table_steps[0].verb, Verb::Create);
6486 assert_eq!(table_steps[0].prompt, "did you create object type 'catalog::Product'?");
6487
6488 let joined: String = table_steps[0]
6491 .ddl
6492 .iter()
6493 .map(|d| d.sql.as_str())
6494 .collect::<Vec<_>>()
6495 .join("\n");
6496 assert!(
6497 joined.contains("CREATE TABLE IF NOT EXISTS \"catalog\".\"Product\""),
6498 "got:\n{joined}"
6499 );
6500 assert!(joined.contains("pylon_cache_invalidate"), "got:\n{joined}");
6501 }
6502
6503 #[test]
6504 fn test_guidance_bans_a_rejected_type_rename_candidate() {
6505 let schema = SchemaDescriptor {
6506 types: vec![simple_type("default", "Customer", "Customer")],
6507 scalars: vec![],
6508 enums: vec![],
6509 named_tuples: vec![],
6510 globals: vec![],
6511 functions: vec![],
6512 aliases: vec![],
6513 channels: vec![],
6514 ..Default::default()
6515 };
6516 let state = DbState {
6517 schemas: vec!["default".into()],
6518 tables: vec![DbTable {
6519 schema: "default".into(),
6520 name: "Person".into(),
6521 columns: vec![
6522 DbColumn {
6523 name: "id".into(),
6524 pg_type: "uuid".into(),
6525 nullable: false,
6526 is_generated: false,
6527 column_default: Some("uuidv7()".into()),
6528 },
6529 DbColumn {
6530 name: "name".into(),
6531 pg_type: "text".into(),
6532 nullable: true,
6533 is_generated: false,
6534 column_default: None,
6535 },
6536 ],
6537 foreign_keys: vec![],
6538 indexes: vec![],
6539 checks: vec![],
6540 triggers: vec![],
6541 }],
6542 enums: vec![],
6543 domains: vec![],
6544 ..DbState::default()
6545 };
6546
6547 let candidates = detect_type_renames(&schema, &state, &Guidance::default());
6548 assert_eq!(
6549 candidates.len(),
6550 1,
6551 "expected Person -> Customer to be proposed as a rename"
6552 );
6553
6554 let mut guidance = Guidance::default();
6555 guidance.banned_type_renames.insert((
6556 "default".to_string(),
6557 "Person".to_string(),
6558 "default".to_string(),
6559 "Customer".to_string(),
6560 ));
6561 let candidates = detect_type_renames(&schema, &state, &guidance);
6562 assert!(
6563 candidates.is_empty(),
6564 "a banned rename candidate must not be re-proposed"
6565 );
6566 }
6567}
6568
6569#[cfg(test)]
6570mod tuple_type_tests {
6571 use super::*;
6572 use crate::schema::{
6573 NamedTupleDescriptor, PropertyDescriptor, SchemaDescriptor, TupleMemberDescriptor, TupleMemberKind,
6574 TypeDescriptor, tuple_type,
6575 };
6576
6577 fn scalar_member(name: &str, pg_type: &str) -> TupleMemberDescriptor {
6578 TupleMemberDescriptor {
6579 name: Some(name.to_string()),
6580 kind: TupleMemberKind::Scalar {
6581 pg_type: pg_type.to_string(),
6582 },
6583 }
6584 }
6585
6586 fn prop(name: &str, pg_type: &str, tuple_members: Option<Vec<TupleMemberDescriptor>>) -> PropertyDescriptor {
6587 PropertyDescriptor {
6588 name: name.into(),
6589 pg_type: pg_type.into(),
6590 nullable: true,
6591 default_sql: None,
6592 default_pyql: None,
6593 description: None,
6594 check_constraints: vec![],
6595 is_exclusive: false,
6596 is_pk: false,
6597 is_readonly: false,
6598 rewrites: vec![],
6599 tuple_members,
6600 column_type: None,
6601 }
6602 }
6603
6604 fn type_with(module: &str, name: &str, properties: Vec<PropertyDescriptor>) -> TypeDescriptor {
6605 TypeDescriptor {
6606 name: name.into(),
6607 module: module.into(),
6608 table: name.into(),
6609 abstract_: false,
6610 materialized: false,
6611 description: None,
6612 parents: vec![],
6613 interfaces: vec![],
6614 bases: vec![],
6615 properties,
6616 links: vec![],
6617 multilinks: vec![],
6618 computed: vec![],
6619 constraints: vec![],
6620 indexes: vec![],
6621 partition: None,
6622 vector_indexes: vec![],
6623 search_indexes: vec![],
6624 triggers: vec![],
6625 junction: false,
6626 signals: vec![],
6627 }
6628 }
6629
6630 fn point_schema(members: Vec<TupleMemberDescriptor>, prop_pg_type: &str) -> SchemaDescriptor {
6633 let mut schema = SchemaDescriptor::default();
6634 schema.named_tuples.push(NamedTupleDescriptor {
6635 name: "Point".into(),
6636 module: "default".into(),
6637 members,
6638 });
6639 schema
6640 .types
6641 .push(type_with("default", "Pin", vec![prop("at", prop_pg_type, None)]));
6642 schema
6643 }
6644
6645 #[test]
6646 fn a_new_tuple_type_is_created_before_the_table_holding_it() {
6647 let schema = point_schema(vec![scalar_member("x", "int8")], "__nt__:default::Point");
6648 let joined = diff_schema(&schema, &DbState::default()).unwrap().join("\n");
6649
6650 let type_pos = joined.find("CREATE TYPE \"public\".\"Point_t\"").expect(&joined);
6651 let table_pos = joined.find("\"public\".\"Pin\" (").expect(&joined);
6652 assert!(type_pos < table_pos, "got:\n{joined}");
6653 assert!(
6654 joined.contains("\"at\" \"public\".\"Point_t\""),
6655 "the column must be declared with the composite type, got:\n{joined}"
6656 );
6657 }
6658
6659 #[test]
6660 fn a_jsonb_column_is_converted_rather_than_cast() {
6661 let schema = point_schema(vec![scalar_member("x", "int8")], "__nt__:default::Point");
6665 let mut current = schema_to_db_state(&schema);
6666 for table in &mut current.tables {
6667 for col in &mut table.columns {
6668 if col.name == "at" {
6669 col.pg_type = "jsonb".into();
6670 }
6671 }
6672 }
6673 let joined = diff_schema(&schema, ¤t).unwrap().join("\n");
6675
6676 assert!(
6677 joined.contains(
6678 "ALTER TABLE \"public\".\"Pin\" ALTER COLUMN \"at\" TYPE \"public\".\"Point_t\" \
6679 USING _pylon.populate_tuple(NULL::\"public\".\"Point_t\", to_jsonb(\"at\"))"
6680 ),
6681 "got:\n{joined}"
6682 );
6683 }
6684
6685 #[test]
6686 fn a_jsonb_array_column_is_converted_element_wise() {
6687 let schema = point_schema(vec![scalar_member("x", "int8")], "__nt__:default::Point[]");
6688 let mut current = schema_to_db_state(&schema);
6689 for table in &mut current.tables {
6690 for col in &mut table.columns {
6691 if col.name == "at" {
6692 col.pg_type = "jsonb[]".into();
6693 }
6694 }
6695 }
6696 let joined = diff_schema(&schema, ¤t).unwrap().join("\n");
6697
6698 assert!(
6699 joined.contains(
6700 "TYPE \"public\".\"Point_t\"[] USING \
6701 _pylon.populate_tuples(NULL::\"public\".\"Point_t\", to_jsonb(\"at\"))"
6702 ),
6703 "got:\n{joined}"
6704 );
6705 }
6706
6707 #[test]
6708 fn a_tuple_type_the_schema_no_longer_declares_is_dropped() {
6709 let before = point_schema(vec![scalar_member("x", "int8")], "__nt__:default::Point");
6710 let mut after = SchemaDescriptor::default();
6711 after
6712 .types
6713 .push(type_with("default", "Pin", vec![prop("at", "text", None)]));
6714
6715 let joined = diff_schema(&after, &schema_to_db_state(&before)).unwrap().join("\n");
6716 assert!(
6717 joined.contains("DROP TYPE IF EXISTS \"public\".\"Point_t\";"),
6718 "got:\n{joined}"
6719 );
6720 }
6721
6722 #[test]
6723 fn an_unchanged_tuple_type_produces_no_ddl() {
6724 let schema = point_schema(vec![scalar_member("x", "int8")], "__nt__:default::Point");
6725 let current = schema_to_db_state(&schema);
6726 let ops = diff_schema(&schema, ¤t).unwrap();
6727 assert!(!ops.iter().any(|o| o.contains("Point_t")), "got:\n{}", ops.join("\n"));
6728 }
6729
6730 #[test]
6731 fn a_member_added_to_a_named_tuple_replaces_the_type_in_order() {
6732 let before = point_schema(vec![scalar_member("x", "int8")], "__nt__:default::Point");
6737 let after = point_schema(
6738 vec![scalar_member("x", "int8"), scalar_member("y", "numeric")],
6739 "__nt__:default::Point",
6740 );
6741
6742 let joined = diff_schema(&after, &schema_to_db_state(&before)).unwrap().join("\n");
6743 let create = joined
6744 .find("CREATE TYPE \"public\".\"Point_t__new\" AS (\"x\" int8, \"y\" numeric)")
6745 .expect(&joined);
6746 let retype = joined
6747 .find("ALTER COLUMN \"at\" TYPE \"public\".\"Point_t__new\"")
6748 .expect(&joined);
6749 let drop = joined.find("DROP TYPE \"public\".\"Point_t\";").expect(&joined);
6750 let rename = joined
6751 .find("ALTER TYPE \"public\".\"Point_t__new\" RENAME TO \"Point_t\"")
6752 .expect(&joined);
6753 assert!(create < retype && retype < drop && drop < rename, "got:\n{joined}");
6754 }
6755
6756 #[test]
6757 fn a_tuple_held_by_another_tuple_is_replaced_along_with_it() {
6758 fn schema_with(point_members: Vec<TupleMemberDescriptor>) -> SchemaDescriptor {
6761 let mut schema = SchemaDescriptor::default();
6762 schema.named_tuples.push(NamedTupleDescriptor {
6763 name: "Point".into(),
6764 module: "default".into(),
6765 members: point_members,
6766 });
6767 schema.named_tuples.push(NamedTupleDescriptor {
6768 name: "Pin".into(),
6769 module: "default".into(),
6770 members: vec![TupleMemberDescriptor {
6771 name: Some("at".into()),
6772 kind: TupleMemberKind::NamedTuple {
6773 module: "default".into(),
6774 name: "Point".into(),
6775 },
6776 }],
6777 });
6778 schema.types.push(type_with(
6779 "default",
6780 "Marker",
6781 vec![prop("pin", "__nt__:default::Pin", None)],
6782 ));
6783 schema
6784 }
6785 let before = schema_with(vec![scalar_member("x", "int8")]);
6786 let after = schema_with(vec![scalar_member("x", "int8"), scalar_member("y", "int8")]);
6787
6788 let joined = diff_schema(&after, &schema_to_db_state(&before)).unwrap().join("\n");
6789 assert!(
6790 joined.contains("CREATE TYPE \"public\".\"Pin_t__new\" AS (\"at\" \"public\".\"Point_t__new\")"),
6791 "the holder's replacement must point at the held type's replacement, got:\n{joined}"
6792 );
6793 assert!(
6795 joined.contains("ALTER COLUMN \"pin\" TYPE \"public\".\"Pin_t__new\""),
6796 "got:\n{joined}"
6797 );
6798 let drop_pin = joined.find("DROP TYPE \"public\".\"Pin_t\";").expect(&joined);
6799 let drop_point = joined.find("DROP TYPE \"public\".\"Point_t\";").expect(&joined);
6800 assert!(
6801 drop_pin < drop_point,
6802 "the holder has to go before the type it holds, got:\n{joined}"
6803 );
6804 }
6805
6806 #[test]
6807 fn a_structural_shape_change_is_a_new_type_and_not_a_replacement() {
6808 let before_members = vec![scalar_member("name", "text")];
6812 let after_members = vec![scalar_member("name", "text"), scalar_member("value", "numeric")];
6813 let mut before = SchemaDescriptor::default();
6814 before.types.push(type_with(
6815 "default",
6816 "Webhook",
6817 vec![prop("headers", "jsonb", Some(before_members.clone()))],
6818 ));
6819 let mut after = SchemaDescriptor::default();
6820 after.types.push(type_with(
6821 "default",
6822 "Webhook",
6823 vec![prop("headers", "jsonb", Some(after_members.clone()))],
6824 ));
6825
6826 let joined = diff_schema(&after, &schema_to_db_state(&before)).unwrap().join("\n");
6827 let old_name = tuple_type::structural_name(&before_members);
6828 let new_name = tuple_type::structural_name(&after_members);
6829 assert!(
6830 joined.contains(&format!("CREATE TYPE \"public\".\"{new_name}\"")),
6831 "got:\n{joined}"
6832 );
6833 assert!(
6834 joined.contains(&format!("DROP TYPE IF EXISTS \"public\".\"{old_name}\";")),
6835 "got:\n{joined}"
6836 );
6837 assert!(!joined.contains("__new"), "no replacement needed, got:\n{joined}");
6838 }
6839
6840 #[test]
6841 fn a_module_that_only_declares_a_named_tuple_gets_its_schema_created() {
6842 let mut schema = SchemaDescriptor::default();
6843 schema.named_tuples.push(NamedTupleDescriptor {
6844 name: "Point".into(),
6845 module: "geo".into(),
6846 members: vec![scalar_member("x", "int8")],
6847 });
6848 let joined = diff_schema(&schema, &DbState::default()).unwrap().join("\n");
6849 let schema_pos = joined.find("CREATE SCHEMA IF NOT EXISTS \"geo\"").expect(&joined);
6850 let type_pos = joined.find("CREATE TYPE \"geo\".\"Point_t\"").expect(&joined);
6851 assert!(schema_pos < type_pos, "got:\n{joined}");
6852 }
6853}
6854
6855#[cfg(test)]
6856mod tuple_type_live_tests {
6857 use super::*;
6862 use crate::schema::{NamedTupleDescriptor, PropertyDescriptor, TupleMemberDescriptor, TupleMemberKind};
6863 use pylon_pgcon::PgPool;
6864
6865 fn test_dsn() -> String {
6866 std::env::var("PYLON_PGCON_TEST_DSN").expect("PYLON_PGCON_TEST_DSN must be set to run live-Postgres tests")
6867 }
6868
6869 fn unique_name(prefix: &str) -> String {
6870 use std::time::{SystemTime, UNIX_EPOCH};
6871 let nanos = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
6872 format!("{prefix}_{nanos}")
6873 }
6874
6875 fn scalar_member(name: &str, pg_type: &str) -> TupleMemberDescriptor {
6876 TupleMemberDescriptor {
6877 name: Some(name.to_string()),
6878 kind: TupleMemberKind::Scalar {
6879 pg_type: pg_type.to_string(),
6880 },
6881 }
6882 }
6883
6884 fn prop(name: &str, pg_type: &str) -> PropertyDescriptor {
6885 PropertyDescriptor {
6886 name: name.into(),
6887 pg_type: pg_type.into(),
6888 nullable: true,
6889 default_sql: None,
6890 default_pyql: None,
6891 description: None,
6892 check_constraints: vec![],
6893 is_exclusive: false,
6894 is_pk: false,
6895 is_readonly: false,
6896 rewrites: vec![],
6897 tuple_members: None,
6898 column_type: None,
6899 }
6900 }
6901
6902 #[tokio::test]
6903 #[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
6904 async fn the_generated_conversion_keeps_every_member_exact() {
6905 let pool = PgPool::connect(&test_dsn(), 5).await.unwrap();
6906 crate::migrate::ensure_internal_schema(&pool).await.unwrap();
6909
6910 let table = unique_name("tuple_upgrade");
6911 let point = unique_name("Point");
6912 pool.batch_execute(&format!(
6913 "CREATE TABLE \"{table}\" (\"at\" jsonb, \"legs\" jsonb[]);
6914 INSERT INTO \"{table}\" VALUES (
6915 '{{\"x\": 1, \"y\": 2.5000}}'::jsonb,
6916 ARRAY['{{\"x\": 3, \"y\": 4.0000}}'::jsonb]
6917 );
6918 INSERT INTO \"{table}\" VALUES (NULL, NULL);"
6919 ))
6920 .await
6921 .unwrap();
6922
6923 let mut target = SchemaDescriptor::default();
6925 target.named_tuples.push(NamedTupleDescriptor {
6926 name: point.clone(),
6927 module: "default".into(),
6928 members: vec![scalar_member("x", "int8"), scalar_member("y", "numeric")],
6929 });
6930 target.types.push(TypeDescriptor {
6931 name: table.clone(),
6932 module: "default".into(),
6933 table: table.clone(),
6934 abstract_: false,
6935 materialized: false,
6936 description: None,
6937 parents: vec![],
6938 interfaces: vec![],
6939 bases: vec![],
6940 properties: vec![
6941 prop("at", &format!("__nt__:default::{point}")),
6942 prop("legs", &format!("__nt__:default::{point}[]")),
6943 ],
6944 links: vec![],
6945 multilinks: vec![],
6946 computed: vec![],
6947 constraints: vec![],
6948 indexes: vec![],
6949 partition: None,
6950 vector_indexes: vec![],
6951 search_indexes: vec![],
6952 triggers: vec![],
6953 junction: false,
6954 signals: vec![],
6955 });
6956
6957 let mut current = schema_to_db_state(&target);
6960 current.composites.clear();
6961 for t in &mut current.tables {
6962 for col in &mut t.columns {
6963 col.pg_type = match col.name.as_str() {
6964 "at" => "jsonb".into(),
6965 "legs" => "jsonb[]".into(),
6966 other => other.into(),
6967 };
6968 }
6969 }
6970
6971 let ops = diff_schema(&target, ¤t).unwrap();
6972 for sql in &ops {
6973 pool.batch_execute(sql)
6974 .await
6975 .unwrap_or_else(|e| panic!("the diff emitted DDL that does not run: {e}\n{sql}"));
6976 }
6977
6978 let rows = pool
6979 .query_typed(
6980 &format!(
6981 "SELECT ((\"at\").\"y\"::text, (\"legs\")[1].\"y\"::text) AS result
6982 FROM \"{table}\" WHERE \"at\" IS NOT NULL"
6983 ),
6984 &[],
6985 &pool.types(),
6986 )
6987 .await
6988 .unwrap();
6989 let [row] = rows.as_slice() else {
6990 panic!("expected exactly the one row that had a value, got {rows:?}")
6991 };
6992 let pylon_value::DecodedValue::Composite(fields) = row else {
6993 panic!("expected a row, got {row:?}")
6994 };
6995 assert_eq!(
6996 fields,
6997 &vec![
6998 pylon_value::DecodedValue::Str("2.5000".into()),
6999 pylon_value::DecodedValue::Str("4.0000".into()),
7000 ],
7001 "every digit a decimal member was written with has to survive the conversion"
7002 );
7003
7004 pool.batch_execute(&format!("DROP TABLE \"{table}\"; DROP TYPE \"{point}_t\";"))
7005 .await
7006 .unwrap();
7007 }
7008
7009 #[tokio::test]
7013 #[ignore = "requires a live Postgres via PYLON_PGCON_TEST_DSN"]
7014 async fn an_empty_array_of_tuples_converts_to_an_empty_array() {
7015 let pool = PgPool::connect(&test_dsn(), 5).await.unwrap();
7016 crate::migrate::ensure_internal_schema(&pool).await.unwrap();
7017
7018 let tuple = unique_name("Header");
7019 pool.batch_execute(&format!(
7020 "CREATE TYPE \"{tuple}_t\" AS (\"name\" text, \"value\" text);"
7021 ))
7022 .await
7023 .unwrap();
7024
7025 let rows = pool
7026 .query_typed(
7027 &format!(
7028 "SELECT (
7029 COALESCE(cardinality(_pylon.populate_tuples(NULL::\"{tuple}_t\", '[]'::jsonb))::text, 'absent'),
7030 COALESCE(cardinality(_pylon.populate_tuples(NULL::\"{tuple}_t\", NULL::jsonb))::text, 'absent'),
7031 COALESCE(cardinality(_pylon.populate_tuples(NULL::\"{tuple}_t\", 'null'::jsonb))::text, 'absent'),
7032 COALESCE(cardinality(_pylon.populate_tuples(
7033 NULL::\"{tuple}_t\",
7034 '[{{\"name\": \"a\", \"value\": \"1\"}}]'::jsonb))::text, 'absent')
7035 ) AS result"
7036 ),
7037 &[],
7038 &pool.types(),
7039 )
7040 .await
7041 .unwrap();
7042 let [pylon_value::DecodedValue::Composite(fields)] = rows.as_slice() else {
7043 panic!("expected one row of four members, got {rows:?}")
7044 };
7045 assert_eq!(
7046 fields,
7047 &vec![
7048 pylon_value::DecodedValue::Str("0".into()),
7049 pylon_value::DecodedValue::Str("absent".into()),
7050 pylon_value::DecodedValue::Str("absent".into()),
7051 pylon_value::DecodedValue::Str("1".into()),
7052 ],
7053 "an empty array stays empty; only a genuinely absent value stays absent"
7054 );
7055
7056 pool.batch_execute(&format!("DROP TYPE \"{tuple}_t\";")).await.unwrap();
7057 }
7058}