1use std::collections::{HashMap, HashSet};
28
29use serde::{Deserialize, Serialize};
30
31use crate::schema::{SchemaDescriptor, SearchBackend, TypeDescriptor, resolved_pg_type};
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)]
48 pub sequences: Vec<DbSequence>,
49 #[serde(default)]
50 pub views: Vec<DbView>,
51 #[serde(default)]
52 pub functions: Vec<DbFunction>,
53 #[serde(default)]
57 pub extensions: Vec<String>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct DbTable {
62 pub schema: String,
63 pub name: String,
64 pub columns: Vec<DbColumn>,
65 pub foreign_keys: Vec<DbForeignKey>,
66 pub indexes: Vec<DbIndex>,
67 pub checks: Vec<DbCheck>,
68 #[serde(default)]
69 pub triggers: Vec<String>,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct DbColumn {
74 pub name: String,
75 pub pg_type: String,
76 pub nullable: bool,
77 pub is_generated: bool,
78 #[serde(default)]
79 pub column_default: Option<String>,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct DbForeignKey {
84 pub constraint_name: String,
85 pub local_column: String,
86 pub ref_schema: String,
87 pub ref_table: String,
88}
89
90fn capped_index_name(name: String) -> String {
94 const MAX: usize = 63;
95 if name.len() <= MAX {
96 return name;
97 }
98 let hash = crate::export::fnv(&[&name]);
99 let head: String = name.chars().take(MAX - 9).collect();
100 format!("{head}_{}", &hash[..8])
101}
102
103fn pointer_column(td: &TypeDescriptor, pointer: &str) -> String {
106 if td.links.iter().any(|l| l.name == pointer && !l.is_junction_backed()) {
107 format!("{pointer}_id")
108 } else {
109 pointer.to_string()
110 }
111}
112
113fn inherited_from_an_interface(schema: &SchemaDescriptor, td: &TypeDescriptor, pointer: &str) -> bool {
117 td.interfaces.iter().any(|iface| {
118 schema
119 .types
120 .iter()
121 .filter(|t| format!("{}::{}", t.module, t.name) == *iface)
122 .any(|t| {
123 t.properties.iter().any(|p| p.name == pointer)
124 || t.links.iter().any(|l| l.name == pointer)
125 || t.multilinks.iter().any(|ml| ml.name == pointer)
126 })
127 })
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct DbIndex {
132 pub name: String,
133 pub is_unique: bool,
134 pub method: String,
135 #[serde(default)]
140 pub columns: Vec<String>,
141 #[serde(default)]
145 pub predicate: Option<String>,
146 #[serde(default)]
150 pub key: Option<String>,
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct DbCheck {
155 pub constraint_name: String,
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct DbEnum {
160 pub schema: String,
161 pub name: String,
162 pub members: Vec<String>,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct DbDomain {
167 pub schema: String,
168 pub name: String,
169 #[serde(default)]
171 pub checks: Vec<String>,
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct DbSequence {
176 pub schema: String,
177 pub name: String,
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct DbView {
182 pub schema: String,
183 pub name: String,
184 pub body_hash: String,
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct DbFunction {
190 pub schema: String,
191 pub name: String,
192 pub body_hash: String,
194}
195
196pub fn schema_to_db_state(schema: &SchemaDescriptor) -> DbState {
205 use std::collections::BTreeSet;
206
207 let type_map: HashMap<String, (&str, &str)> = schema
208 .types
209 .iter()
210 .map(|t| {
211 (
212 format!("{}::{}", t.module, t.name),
213 (t.module.as_str(), t.table.as_str()),
214 )
215 })
216 .collect();
217
218 let mut schema_set: BTreeSet<String> = BTreeSet::new();
220 for t in &schema.types {
221 schema_set.insert(t.module.clone());
222 }
223 for e in &schema.enums {
224 schema_set.insert(e.module.clone());
225 }
226 for s in &schema.scalars {
227 schema_set.insert(s.module.clone());
228 }
229
230 let schemas: Vec<String> = schema_set.into_iter().collect();
231
232 let enums: Vec<DbEnum> = schema
234 .enums
235 .iter()
236 .map(|e| DbEnum {
237 schema: e.module.clone(),
238 name: e.name.clone(),
239 members: e.members.clone(),
240 })
241 .collect();
242
243 let domains: Vec<DbDomain> = schema
245 .scalars
246 .iter()
247 .map(|s| DbDomain {
248 schema: s.module.clone(),
249 name: s.name.clone(),
250 checks: crate::export::scalar_check_constraints(schema)
251 .into_iter()
252 .filter(|(module, name, _, _)| *module == s.module && *name == s.name)
253 .map(|(_, _, cname, _)| cname)
254 .collect(),
255 })
256 .collect();
257
258 let sequences: Vec<DbSequence> = schema
260 .scalars
261 .iter()
262 .filter(|s| s.is_sequence)
263 .map(|s| DbSequence {
264 schema: s.module.clone(),
265 name: format!("{}_seq", s.name),
266 })
267 .collect();
268
269 let expected_trigger_names = expected_triggers(schema, &type_map);
270 let mut expected_checks: HashMap<(String, String), Vec<String>> = HashMap::new();
271 for (module, table, cname, _) in crate::export::check_constraints(schema).unwrap_or_default() {
272 expected_checks.entry((module, table)).or_default().push(cname);
273 }
274 let mut tables: Vec<DbTable> = Vec::new();
275
276 for td in &schema.types {
277 if td.abstract_ || td.junction {
278 continue;
279 }
280
281 let mut columns: Vec<DbColumn> = Vec::new();
283 for p in &td.properties {
284 columns.push(DbColumn {
285 name: p.name.clone(),
286 pg_type: col_type_str(p).to_string(),
287 nullable: p.nullable,
288 is_generated: false,
289 column_default: resolve_default(p, schema),
290 });
291 }
292 for l in &td.links {
293 if l.is_junction_backed() {
297 continue;
298 }
299 columns.push(DbColumn {
300 name: format!("{}_id", l.name),
301 pg_type: "uuid".to_string(),
302 nullable: l.nullable,
303 is_generated: false,
304 column_default: resolve_link_default(l, schema),
305 });
306 }
307 for vi in &td.vector_indexes {
309 let col = vi.column_name();
310 if !columns.iter().any(|c| c.name == col) {
311 columns.push(DbColumn {
312 name: col,
313 pg_type: format!("vector({})", vi.dimensions),
314 nullable: true,
315 is_generated: false,
316 column_default: None,
317 });
318 }
319 }
320 for si in &td.search_indexes {
322 if si.backend != SearchBackend::Postgres {
323 continue;
324 }
325 let col = si.column_name();
326 if !columns.iter().any(|c| c.name == col) {
327 columns.push(DbColumn {
328 name: col,
329 pg_type: "tsvector".to_string(),
330 nullable: true,
331 is_generated: true,
332 column_default: None,
333 });
334 }
335 }
336
337 let mut foreign_keys: Vec<DbForeignKey> = Vec::new();
339 for l in &td.links {
340 if l.is_junction_backed() {
341 continue;
342 }
343 let cname = format!("{}_{}_fkey", td.table, l.name);
344 if let Some((tgt_schema, tgt_table)) = type_map.get(&l.target) {
345 foreign_keys.push(DbForeignKey {
346 constraint_name: cname,
347 local_column: format!("{}_id", l.name),
348 ref_schema: tgt_schema.to_string(),
349 ref_table: tgt_table.to_string(),
350 });
351 }
352 }
353
354 let mut indexes: Vec<DbIndex> = Vec::new();
356 for p in &td.properties {
357 if p.is_exclusive && !p.is_pk && !inherited_from_an_interface(schema, td, &p.name) {
362 indexes.push(DbIndex {
363 name: format!("{}_{}_key", td.table, p.name),
364 is_unique: true,
365 method: "btree".to_string(),
366 columns: vec![p.name.clone()],
367 predicate: None,
368 key: None,
369 });
370 }
371 }
372 for l in &td.links {
373 if l.is_exclusive && !l.is_junction_backed() && !inherited_from_an_interface(schema, td, &l.name) {
377 indexes.push(DbIndex {
378 name: format!("{}_{}_id_key", td.table, l.name),
379 is_unique: true,
380 method: "btree".to_string(),
381 columns: vec![format!("{}_id", l.name)],
382 predicate: None,
383 key: None,
384 });
385 }
386 }
387 for (i, constraint) in td.constraints.iter().enumerate() {
388 use crate::schema::TypeConstraint;
389 if let TypeConstraint::Exclusive {
390 pointers: fields,
391 unless,
392 } = constraint
393 {
394 let idx_name = format!("{}_{}_{}_key", td.table, fields.join("_"), i);
396 indexes.push(DbIndex {
397 name: idx_name,
398 is_unique: true,
399 method: "btree".to_string(),
400 columns: fields.iter().map(|f| pointer_column(td, f)).collect(),
401 predicate: unless.as_deref().and_then(|u| {
402 let qualified = format!("{}::{}", td.module, td.name);
403 crate::ir::compile_constraint_expr(u, &qualified, schema)
404 .ok()
405 .map(|c| format!(" WHERE NOT ({c})"))
406 }),
407 key: None,
408 });
409 }
410 }
411 for (i, idx) in td.indexes.iter().enumerate() {
413 let name = if idx.expression.is_some() {
414 format!("{}__expr{}_idx", td.table, i)
415 } else {
416 format!("{}__{}_idx", td.table, idx.pointers.join("_"))
417 };
418 let Ok((body, predicate)) = crate::export::index_body_and_predicate(
422 td,
423 &idx.pointers,
424 idx.expression.as_deref(),
425 idx.unless.as_deref(),
426 schema,
427 ) else {
428 continue;
429 };
430 indexes.push(DbIndex {
431 name,
432 is_unique: idx.unique,
433 method: "btree".to_string(),
434 columns: vec![],
435 key: Some(body),
436 predicate: (!predicate.is_empty()).then_some(predicate),
437 });
438 }
439 for vi in &td.vector_indexes {
441 let idx_name = match &vi.index_name {
442 None => format!("{}__vector__", td.table),
443 Some(n) => format!("{}__vector_{}__", td.table, n),
444 };
445 indexes.push(DbIndex {
446 name: idx_name,
447 is_unique: false,
448 method: "hnsw".to_string(),
449 columns: vec![],
450 predicate: None,
451 key: None,
452 });
453 }
454 for si in &td.search_indexes {
456 if si.backend != SearchBackend::Postgres {
457 continue;
458 }
459 let idx_name = match &si.index_name {
460 None => format!("{}__search__", td.table),
461 Some(n) => format!("{}__search_{}__", td.table, n),
462 };
463 indexes.push(DbIndex {
464 name: idx_name,
465 is_unique: false,
466 method: "gin".to_string(),
467 columns: vec![],
468 predicate: None,
469 key: None,
470 });
471 }
472
473 let checks: Vec<DbCheck> = expected_checks
477 .get(&(td.module.clone(), td.table.clone()))
478 .cloned()
479 .unwrap_or_default()
480 .into_iter()
481 .map(|constraint_name| DbCheck { constraint_name })
482 .collect();
483
484 let triggers: Vec<String> = expected_trigger_names
485 .get(&(td.module.clone(), td.table.clone()))
486 .cloned()
487 .unwrap_or_default()
488 .into_iter()
489 .collect();
490 tables.push(DbTable {
491 schema: td.module.clone(),
492 name: td.table.clone(),
493 columns,
494 foreign_keys,
495 indexes,
496 checks,
497 triggers,
498 });
499
500 for ml in &td.multilinks {
502 tables.push(build_junction_db_table(
503 schema,
504 &type_map,
505 td,
506 &ml.name,
507 &ml.target,
508 ml.through.as_deref(),
509 ml.is_exclusive,
510 &expected_trigger_names,
511 ));
512 }
513 for l in &td.links {
520 if !l.is_junction_backed() {
521 continue;
522 }
523 tables.push(build_junction_db_table(
524 schema,
525 &type_map,
526 td,
527 &l.name,
528 &l.target,
529 l.through.as_deref(),
530 l.is_exclusive,
531 &expected_trigger_names,
532 ));
533 }
534 }
535
536 let views: Vec<DbView> = crate::export::interface_view_ddl_with_names(schema)
541 .into_iter()
542 .chain(crate::export::interface_junction_view_ddl_with_names(schema))
543 .map(|(module, name, ddl)| DbView {
544 schema: module,
545 name,
546 body_hash: ddl_hash(&ddl),
547 })
548 .collect();
549
550 let functions: Vec<DbFunction> = crate::export::function_ddl_with_names(schema)
552 .unwrap_or_default()
553 .into_iter()
554 .map(|(module, name, ddl)| DbFunction {
555 schema: module,
556 name,
557 body_hash: ddl_hash(&ddl),
558 })
559 .collect();
560
561 let extensions: Vec<String> = required_extensions(schema).iter().map(|s| s.to_string()).collect();
562
563 let polymorphic_tables: HashSet<(&str, &str)> = {
565 let polymorphic = crate::export::polymorphic_types(schema);
566 schema
567 .types
568 .iter()
569 .filter(|t| polymorphic.contains(&format!("{}::{}", t.module, t.name)))
570 .map(|t| (t.module.as_str(), t.table.as_str()))
571 .collect()
572 };
573 for table in &mut tables {
574 table.foreign_keys.retain(|fk| {
575 fk.local_column == "source"
576 || !polymorphic_tables.contains(&(fk.ref_schema.as_str(), fk.ref_table.as_str()))
577 });
578 }
579
580 DbState {
581 schemas,
582 tables,
583 enums,
584 domains,
585 sequences,
586 views,
587 functions,
588 extensions,
589 }
590}
591
592pub fn required_extensions(target: &SchemaDescriptor) -> Vec<&'static str> {
598 let mut out = Vec::new();
599 if target.types.iter().any(|t| !t.vector_indexes.is_empty()) {
600 out.push("vector");
601 }
602 if target.types.iter().any(|t| t.partition.is_some()) {
603 out.push("pg_partman");
604 }
605 out
606}
607
608pub fn missing_extension_ddl(target: &SchemaDescriptor, current: &DbState) -> Vec<String> {
615 required_extensions(target)
616 .into_iter()
617 .filter(|ext| !current.extensions.iter().any(|e| e == ext))
618 .map(|ext| format!("CREATE EXTENSION IF NOT EXISTS \"{ext}\";"))
619 .collect()
620}
621
622#[allow(clippy::too_many_arguments)]
629fn build_junction_db_table(
630 schema: &SchemaDescriptor,
631 type_map: &HashMap<String, (&str, &str)>,
632 td: &TypeDescriptor,
633 name: &str,
634 target: &str,
635 through: Option<&str>,
636 exclusive: bool,
637 expected_trigger_names: &HashMap<(String, String), HashSet<String>>,
638) -> DbTable {
639 let jt_name = format!("{}.{}", td.table, name);
640 let mut jt_columns = vec![
641 DbColumn {
642 name: "source".to_string(),
643 pg_type: "uuid".to_string(),
644 nullable: false,
645 is_generated: false,
646 column_default: None,
647 },
648 DbColumn {
649 name: "target".to_string(),
650 pg_type: "uuid".to_string(),
651 nullable: false,
652 is_generated: false,
653 column_default: None,
654 },
655 ];
656
657 if let Some(through_qname) = through
659 && let Some(through_td) = schema
660 .types
661 .iter()
662 .find(|t| format!("{}::{}", t.module, t.name) == *through_qname && t.junction)
663 {
664 for p in &through_td.properties {
665 if p.name == "id" {
666 continue;
667 }
668 let pg_type = col_type_str(p).to_string();
669 jt_columns.push(DbColumn {
670 name: p.name.clone(),
671 pg_type,
672 nullable: p.nullable,
673 is_generated: false,
674 column_default: p.default_sql.clone(),
675 });
676 }
677 }
678
679 let mut jt_fks = Vec::new();
680 let src_fk_name = format!("{}_{}_source_fkey", td.table, name);
681 jt_fks.push(DbForeignKey {
682 constraint_name: src_fk_name,
683 local_column: "source".to_string(),
684 ref_schema: td.module.clone(),
685 ref_table: td.table.clone(),
686 });
687 if let Some((tgt_schema, tgt_table)) = type_map.get(target) {
688 let tgt_fk_name = format!("{}_{}_target_fkey", td.table, name);
689 jt_fks.push(DbForeignKey {
690 constraint_name: tgt_fk_name,
691 local_column: "target".to_string(),
692 ref_schema: tgt_schema.to_string(),
693 ref_table: tgt_table.to_string(),
694 });
695 }
696
697 let mut indexes = Vec::new();
700 if exclusive && !inherited_from_an_interface(schema, td, name) {
701 indexes.push(DbIndex {
702 name: format!("{jt_name}_target_key"),
703 is_unique: true,
704 method: "btree".to_string(),
705 columns: vec!["target".to_string()],
706 predicate: None,
707 key: None,
708 });
709 }
710
711 let triggers: Vec<String> = expected_trigger_names
712 .get(&(td.module.clone(), jt_name.clone()))
713 .cloned()
714 .unwrap_or_default()
715 .into_iter()
716 .collect();
717
718 DbTable {
719 schema: td.module.clone(),
720 name: jt_name,
721 columns: jt_columns,
722 foreign_keys: jt_fks,
723 indexes,
724 checks: vec![],
725 triggers,
726 }
727}
728
729fn ddl_hash(ddl: &str) -> String {
730 use sha2::{Digest, Sha256};
731 let digest = Sha256::digest(ddl.as_bytes());
732 hex::encode(&digest[..8])
733}
734
735pub fn db_state_to_json(state: &DbState) -> String {
737 serde_json::to_string(state).expect("DbState serialization is infallible")
738}
739
740pub fn db_state_from_json(json: &str) -> Result<DbState, String> {
742 serde_json::from_str(json).map_err(|e| e.to_string())
743}
744
745impl DbState {
746 pub fn add_trigger(&mut self, module: &str, table: &str, trigger_name: &str) {
748 if let Some(t) = self.tables.iter_mut().find(|t| t.schema == module && t.name == table) {
749 t.triggers.push(trigger_name.to_string());
750 }
751 }
752}
753
754fn junction_table_belongs_to_an_interface(schema: &SchemaDescriptor, td: &TypeDescriptor) -> bool {
780 let Some((owner_table, _)) = td.table.rsplit_once('.') else {
781 return false;
782 };
783 schema
784 .types
785 .iter()
786 .any(|t| t.abstract_ && t.module == td.module && t.table == owner_table)
787}
788
789pub fn expected_triggers(
790 schema: &SchemaDescriptor,
791 type_map: &HashMap<String, (&str, &str)>,
792) -> HashMap<(String, String), HashSet<String>> {
793 let mut expected: HashMap<(String, String), HashSet<String>> = HashMap::new();
794
795 for info in crate::export::interface_exclusive_trigger_infos(schema) {
796 expected
797 .entry((info.impl_module.clone(), info.impl_table.clone()))
798 .or_default()
799 .extend([info.ins_trigger_name, info.upd_trigger_name]);
800 }
801 for info in crate::export::deletion_policy_trigger_infos(schema, type_map) {
802 expected
803 .entry((info.table_module.clone(), info.table_name.clone()))
804 .or_default()
805 .insert(info.trigger_name);
806 }
807 for info in crate::export::signal_trigger_infos(schema) {
808 expected
809 .entry((info.table_module.clone(), info.table_name.clone()))
810 .or_default()
811 .insert(info.trigger_name);
812 }
813 for (module, table, name) in crate::export::user_trigger_names(schema) {
814 expected.entry((module, table)).or_default().insert(name);
815 }
816
817 let mut cache_trigger_tables: HashSet<(String, String)> = HashSet::new();
822 for td in &schema.types {
823 if td.abstract_ || junction_table_belongs_to_an_interface(schema, td) {
824 continue;
825 }
826 cache_trigger_tables.insert((td.module.clone(), td.table.clone()));
827 if !td.junction {
828 for ml in &td.multilinks {
829 cache_trigger_tables.insert((td.module.clone(), format!("{}.{}", td.table, ml.name)));
830 }
831 for l in &td.links {
832 if !l.is_junction_backed() {
833 continue;
834 }
835 cache_trigger_tables.insert((td.module.clone(), format!("{}.{}", td.table, l.name)));
836 }
837 }
838 }
839 for key in cache_trigger_tables {
840 expected
841 .entry(key)
842 .or_default()
843 .insert("pylon_cache_invalidate".to_string());
844 }
845
846 expected
847}
848
849#[derive(Debug)]
854pub struct TypeRenameCandidate {
855 pub old_module: String,
856 pub old_table: String,
857 pub new_module: String,
858 pub new_table: String,
859 pub new_type_name: String,
861 pub confidence: f64,
863}
864
865#[derive(Debug)]
868pub struct ColRenameCandidate {
869 pub module: String,
870 pub table: String,
871 pub old_col: String,
872 pub new_col: String,
873 pub pg_type: String,
874}
875
876#[derive(Debug)]
880pub struct FillRequired {
881 pub module: String,
883 pub table: String,
884 pub column: String,
885 pub pg_type: String,
886 pub type_name: String,
888 pub is_new_column: bool,
891 pub default_sql: Option<String>,
894}
895
896#[derive(Debug, Clone)]
900pub struct DiffOp {
901 pub sql: String,
902 pub non_transactional: bool,
906}
907
908#[derive(Debug, Clone, Copy, PartialEq, Eq)]
915pub enum Verb {
916 Create,
917 Alter,
918 Drop,
919 Rename,
920}
921
922impl Verb {
923 pub fn as_str(&self) -> &'static str {
924 match self {
925 Verb::Create => "create",
926 Verb::Alter => "alter",
927 Verb::Drop => "drop",
928 Verb::Rename => "rename",
929 }
930 }
931}
932
933#[derive(Debug, Clone, PartialEq, Eq, Hash)]
937pub enum OpKey {
938 Module(String),
939 Scalar(String, String),
942 Table(String, String),
948 Function(String, String),
949 View(String, String),
950 ForeignKey(String, String),
961}
962
963#[derive(Debug)]
964pub struct MigrationStep {
965 pub prompt: String,
968 pub verb: Verb,
969 pub object_desc: String,
971 pub ddl: Vec<DiffOp>,
975 pub op_key: OpKey,
976 pub required_input: Vec<RequiredInput>,
980}
981
982impl MigrationStep {
983 pub fn resolved_ddl(&self, overrides: &HashMap<String, String>) -> Vec<DiffOp> {
987 self.ddl
988 .iter()
989 .map(|op| {
990 let mut sql = op.sql.clone();
991 for input in &self.required_input {
992 let value = overrides.get(&input.placeholder).unwrap_or(&input.default_expr);
993 sql = sql.replace(&format!("\\({})", input.placeholder), value);
994 }
995 DiffOp {
996 sql,
997 non_transactional: op.non_transactional,
998 }
999 })
1000 .collect()
1001 }
1002}
1003
1004#[derive(Debug, Clone)]
1010pub struct RequiredInput {
1011 pub placeholder: String,
1013 pub prompt: String,
1015 pub default_expr: String,
1019 pub type_name: String,
1021}
1022
1023fn verbosename_module(name: &str) -> String {
1024 format!("module '{name}'")
1025}
1026
1027fn is_generated_check_name(name: &str) -> bool {
1031 let Some(rest) = name.strip_suffix("_check") else {
1032 return false;
1033 };
1034 match rest.rsplit_once('_') {
1035 Some((prefix, hash)) => !prefix.is_empty() && hash.len() == 8 && hash.chars().all(|c| c.is_ascii_hexdigit()),
1036 None => false,
1037 }
1038}
1039
1040fn verbosename_scalar(module: &str, name: &str) -> String {
1044 format!("scalar type '{module}::{name}'")
1045}
1046
1047fn verbosename_type(module: &str, name: &str) -> String {
1048 format!("object type '{module}::{name}'")
1049}
1050
1051fn verbosename_interface(module: &str, name: &str) -> String {
1052 format!("interface type '{module}::{name}'")
1053}
1054
1055fn verbosename_function(module: &str, name: &str) -> String {
1056 format!("function '{module}::{name}'")
1057}
1058
1059#[derive(Default)]
1064struct StepBuilder {
1065 order: Vec<OpKey>,
1066 drafts: HashMap<OpKey, (Verb, String, Vec<DiffOp>, Vec<RequiredInput>)>,
1067}
1068
1069impl StepBuilder {
1070 fn new() -> Self {
1071 Self::default()
1072 }
1073
1074 fn push(&mut self, key: OpKey, verb: Verb, object_desc: impl Into<String>, op: DiffOp) {
1075 self.extend(key, verb, object_desc, vec![op]);
1076 }
1077
1078 fn extend(&mut self, key: OpKey, verb: Verb, object_desc: impl Into<String>, ops: Vec<DiffOp>) {
1079 self.extend_with_input(key, verb, object_desc, ops, vec![]);
1080 }
1081
1082 fn extend_with_input(
1083 &mut self,
1084 key: OpKey,
1085 verb: Verb,
1086 object_desc: impl Into<String>,
1087 ops: Vec<DiffOp>,
1088 inputs: Vec<RequiredInput>,
1089 ) {
1090 if ops.is_empty() && inputs.is_empty() {
1091 return;
1092 }
1093 use std::collections::hash_map::Entry;
1094 match self.drafts.entry(key.clone()) {
1095 Entry::Occupied(mut e) => {
1096 e.get_mut().2.extend(ops);
1097 e.get_mut().3.extend(inputs);
1098 }
1099 Entry::Vacant(e) => {
1100 e.insert((verb, object_desc.into(), ops, inputs));
1101 self.order.push(key);
1102 }
1103 }
1104 }
1105
1106 fn position(&self, key: &OpKey) -> Option<usize> {
1108 self.order.iter().position(|existing| existing == key)
1109 }
1110
1111 fn finish(self) -> Vec<MigrationStep> {
1112 let Self { order, mut drafts } = self;
1113 order
1114 .into_iter()
1115 .map(|key| {
1116 let (verb, object_desc, ddl, required_input) = drafts.remove(&key).unwrap();
1117 let prompt = format!("did you {} {}?", verb.as_str(), object_desc);
1118 MigrationStep {
1119 prompt,
1120 verb,
1121 object_desc,
1122 ddl,
1123 op_key: key,
1124 required_input,
1125 }
1126 })
1127 .collect()
1128 }
1129}
1130
1131#[derive(Debug, Default, Clone)]
1136pub struct Guidance {
1137 pub banned_type_renames: HashSet<(String, String, String, String)>,
1140 pub banned_col_renames: HashSet<(String, String, String, String)>,
1143}
1144
1145pub fn diff_schema(target: &SchemaDescriptor, current: &DbState) -> Result<Vec<String>, String> {
1151 Ok(flatten_ops(diff_inner(target, current, false, &HashMap::new())?)
1152 .into_iter()
1153 .map(|op| op.sql)
1154 .collect())
1155}
1156
1157pub fn diff_schema_ops(target: &SchemaDescriptor, current: &DbState) -> Result<Vec<DiffOp>, String> {
1161 Ok(flatten_ops(diff_inner(target, current, true, &HashMap::new())?))
1162}
1163
1164pub fn schema_content_changed(target: &SchemaDescriptor, previous: Option<&SchemaDescriptor>) -> bool {
1200 let previous = previous.cloned().unwrap_or_default();
1201 serde_json::to_value(target).ok() != serde_json::to_value(&previous).ok()
1206}
1207
1208pub fn diff_schema_steps(
1214 target: &SchemaDescriptor,
1215 current: &DbState,
1216 fill_index: &HashMap<(String, String), HashSet<String>>,
1217) -> Result<Vec<MigrationStep>, String> {
1218 diff_inner(target, current, true, fill_index)
1219}
1220
1221fn flatten_ops(steps: Vec<MigrationStep>) -> Vec<DiffOp> {
1228 let no_overrides = HashMap::new();
1229 steps.iter().flat_map(|s| s.resolved_ddl(&no_overrides)).collect()
1230}
1231
1232pub fn diff_states(before: &DbState, after: &DbState) -> Vec<DiffOp> {
1237 diff_states_inner(before, after)
1238}
1239
1240pub fn detect_type_renames(
1246 target: &SchemaDescriptor,
1247 current: &DbState,
1248 guidance: &Guidance,
1249) -> Vec<TypeRenameCandidate> {
1250 let target_keys: HashSet<(&str, &str)> = target
1251 .types
1252 .iter()
1253 .filter(|t| !t.abstract_ && !t.junction)
1254 .map(|t| (t.module.as_str(), t.table.as_str()))
1255 .collect();
1256 let current_keys: HashSet<(&str, &str)> = current
1257 .tables
1258 .iter()
1259 .map(|t| (t.schema.as_str(), t.name.as_str()))
1260 .collect();
1261
1262 let dropped: Vec<&DbTable> = current
1263 .tables
1264 .iter()
1265 .filter(|t| !target_keys.contains(&(t.schema.as_str(), t.name.as_str())))
1266 .collect();
1267 let created: Vec<&TypeDescriptor> = target
1268 .types
1269 .iter()
1270 .filter(|t| !t.abstract_ && !t.junction)
1271 .filter(|t| !current_keys.contains(&(t.module.as_str(), t.table.as_str())))
1272 .collect();
1273
1274 if dropped.is_empty() || created.is_empty() {
1275 return vec![];
1276 }
1277
1278 let mut candidates: Vec<TypeRenameCandidate> = Vec::new();
1279 for dropped_t in &dropped {
1280 let old_cols: HashSet<&str> = dropped_t
1281 .columns
1282 .iter()
1283 .map(|c| c.name.as_str())
1284 .filter(|n| !n.starts_with("__"))
1285 .collect();
1286 for new_type in &created {
1287 let new_cols: HashSet<&str> = new_type.properties.iter().map(|p| p.name.as_str()).collect();
1288 let intersection = old_cols.intersection(&new_cols).count();
1289 let union_size = old_cols.union(&new_cols).count();
1290 if union_size == 0 {
1291 continue;
1292 }
1293 let confidence = intersection as f64 / union_size as f64;
1294 let banned = guidance.banned_type_renames.contains(&(
1295 dropped_t.schema.clone(),
1296 dropped_t.name.clone(),
1297 new_type.module.clone(),
1298 new_type.table.clone(),
1299 ));
1300 if confidence >= 0.4 && !banned {
1301 candidates.push(TypeRenameCandidate {
1302 old_module: dropped_t.schema.clone(),
1303 old_table: dropped_t.name.clone(),
1304 new_module: new_type.module.clone(),
1305 new_table: new_type.table.clone(),
1306 new_type_name: new_type.name.clone(),
1307 confidence,
1308 });
1309 }
1310 }
1311 }
1312 candidates.sort_by(|a, b| {
1313 b.confidence
1314 .partial_cmp(&a.confidence)
1315 .unwrap_or(std::cmp::Ordering::Equal)
1316 });
1317 candidates
1318}
1319
1320pub fn detect_col_renames(
1325 target: &SchemaDescriptor,
1326 current: &DbState,
1327 guidance: &Guidance,
1328) -> Vec<ColRenameCandidate> {
1329 let cur_tables: HashMap<(&str, &str), &DbTable> = current
1330 .tables
1331 .iter()
1332 .map(|t| ((t.schema.as_str(), t.name.as_str()), t))
1333 .collect();
1334
1335 let mut candidates: Vec<ColRenameCandidate> = Vec::new();
1336 for td in &target.types {
1337 if td.abstract_ || td.junction {
1338 continue;
1339 }
1340 let Some(cur) = cur_tables.get(&(td.module.as_str(), td.table.as_str())) else {
1341 continue;
1342 };
1343
1344 let target_cols: Vec<(String, String)> = td
1346 .properties
1347 .iter()
1348 .map(|p| (p.name.clone(), col_type_str(p).to_string()))
1349 .chain(
1350 td.links
1351 .iter()
1352 .filter(|l| !l.is_junction_backed())
1353 .map(|l| (format!("{}_id", l.name), "uuid".to_string())),
1354 )
1355 .collect();
1356
1357 let cur_cols: Vec<(&str, &str)> = cur
1359 .columns
1360 .iter()
1361 .filter(|c| !c.name.starts_with("__"))
1362 .map(|c| (c.name.as_str(), c.pg_type.as_str()))
1363 .collect();
1364
1365 let dropped: Vec<(&str, &str)> = cur_cols
1367 .iter()
1368 .copied()
1369 .filter(|(name, _)| !target_cols.iter().any(|(t, _)| t.as_str() == *name))
1370 .collect();
1371 let added: Vec<(&str, &str)> = target_cols
1373 .iter()
1374 .filter(|(name, _)| !cur_cols.iter().any(|&(c, _)| c == name.as_str()))
1375 .map(|(n, t)| (n.as_str(), t.as_str()))
1376 .collect();
1377
1378 if dropped.is_empty() || added.is_empty() {
1379 continue;
1380 }
1381
1382 let mut dropped_by_type: HashMap<&str, Vec<&str>> = HashMap::new();
1385 for (name, pg_type) in &dropped {
1386 dropped_by_type.entry(pg_type).or_default().push(name);
1387 }
1388 let mut added_by_type: HashMap<&str, Vec<&str>> = HashMap::new();
1389 for (name, pg_type) in &added {
1390 added_by_type.entry(pg_type).or_default().push(name);
1391 }
1392
1393 for (pg_type, dropped_names) in &dropped_by_type {
1394 if let Some(added_names) = added_by_type.get(pg_type)
1395 && dropped_names.len() == 1
1396 && added_names.len() == 1
1397 {
1398 let banned = guidance.banned_col_renames.contains(&(
1399 td.module.clone(),
1400 td.table.clone(),
1401 dropped_names[0].to_string(),
1402 added_names[0].to_string(),
1403 ));
1404 if !banned {
1405 candidates.push(ColRenameCandidate {
1406 module: td.module.clone(),
1407 table: td.table.clone(),
1408 old_col: dropped_names[0].to_string(),
1409 new_col: added_names[0].to_string(),
1410 pg_type: pg_type.to_string(),
1411 });
1412 }
1413 }
1414 }
1415 }
1416 candidates
1417}
1418
1419pub fn detect_fill_required(target: &SchemaDescriptor, current: &DbState) -> Vec<FillRequired> {
1430 let cur_tables: HashMap<(&str, &str), &DbTable> = current
1431 .tables
1432 .iter()
1433 .map(|t| ((t.schema.as_str(), t.name.as_str()), t))
1434 .collect();
1435
1436 let mut result: Vec<FillRequired> = Vec::new();
1437
1438 for td in &target.types {
1439 if td.abstract_ || td.junction {
1440 continue;
1441 }
1442 let Some(cur) = cur_tables.get(&(td.module.as_str(), td.table.as_str())) else {
1443 continue;
1444 };
1445
1446 let cur_col_map: HashMap<&str, &DbColumn> = cur.columns.iter().map(|c| (c.name.as_str(), c)).collect();
1447
1448 for p in &td.properties {
1449 if p.nullable || p.is_pk {
1450 continue;
1451 }
1452 match cur_col_map.get(p.name.as_str()) {
1453 None => {
1454 if p.default_sql.is_none() {
1456 result.push(FillRequired {
1457 module: td.module.clone(),
1458 table: td.table.clone(),
1459 column: p.name.clone(),
1460 pg_type: col_type_str(p).to_string(),
1461 type_name: td.name.clone(),
1462 is_new_column: true,
1463 default_sql: None,
1464 });
1465 }
1466 }
1467 Some(cur_col) if cur_col.nullable => {
1468 result.push(FillRequired {
1470 module: td.module.clone(),
1471 table: td.table.clone(),
1472 column: p.name.clone(),
1473 pg_type: col_type_str(p).to_string(),
1474 type_name: td.name.clone(),
1475 is_new_column: false,
1476 default_sql: p.default_sql.clone(),
1477 });
1478 }
1479 _ => {}
1480 }
1481 }
1482
1483 for l in &td.links {
1484 if l.nullable || l.is_junction_backed() {
1485 continue;
1486 }
1487 let col = format!("{}_id", l.name);
1488 match cur_col_map.get(col.as_str()) {
1489 None => {
1490 result.push(FillRequired {
1491 module: td.module.clone(),
1492 table: td.table.clone(),
1493 column: col,
1494 pg_type: "uuid".to_string(),
1495 type_name: td.name.clone(),
1496 is_new_column: true,
1497 default_sql: None,
1498 });
1499 }
1500 Some(cur_col) if cur_col.nullable => {
1501 result.push(FillRequired {
1502 module: td.module.clone(),
1503 table: td.table.clone(),
1504 column: col,
1505 pg_type: "uuid".to_string(),
1506 type_name: td.name.clone(),
1507 is_new_column: false,
1508 default_sql: None,
1509 });
1510 }
1511 _ => {}
1512 }
1513 }
1514 }
1515 result
1516}
1517
1518fn apply_renames(
1529 state: &mut DbState,
1530 type_renames: &[(String, String, String, String)],
1531 col_renames: &[(String, String, String, String)],
1532) {
1533 for (old_mod, old_table, new_mod, new_table) in type_renames {
1534 if let Some(t) = state
1535 .tables
1536 .iter_mut()
1537 .find(|t| &t.schema == old_mod && &t.name == old_table)
1538 {
1539 t.schema = new_mod.clone();
1540 t.name = new_table.clone();
1541 }
1542 }
1543 for (module, table, old_col, new_col) in col_renames {
1544 if let Some(t) = state
1545 .tables
1546 .iter_mut()
1547 .find(|t| &t.schema == module && &t.name == table)
1548 && let Some(col) = t.columns.iter_mut().find(|c| &c.name == old_col)
1549 {
1550 col.name = new_col.clone();
1551 }
1552 }
1553}
1554
1555pub fn diff_schema_ops_with_renames_and_fills(
1569 target: &SchemaDescriptor,
1570 current: &DbState,
1571 type_renames: &[(String, String, String, String)],
1572 col_renames: &[(String, String, String, String)],
1573 fills: &[(String, String, String, String)],
1574) -> Result<Vec<DiffOp>, String> {
1575 let mut ops: Vec<DiffOp> = Vec::new();
1576 let mut modified = current.clone();
1577
1578 for (old_mod, old_table, new_mod, new_table) in type_renames {
1580 if old_mod == new_mod {
1581 push_tx(
1582 &mut ops,
1583 format!("ALTER TABLE {} RENAME TO {};", qn(old_mod, old_table), qi(new_table)),
1584 );
1585 } else {
1586 push_tx(
1587 &mut ops,
1588 format!("ALTER TABLE {} SET SCHEMA {};", qn(old_mod, old_table), qi(new_mod)),
1589 );
1590 push_tx(
1591 &mut ops,
1592 format!("ALTER TABLE {} RENAME TO {};", qn(new_mod, old_table), qi(new_table)),
1593 );
1594 }
1595 }
1596 for (module, table, old_col, new_col) in col_renames {
1597 push_tx(
1598 &mut ops,
1599 format!(
1600 "ALTER TABLE {} RENAME COLUMN {} TO {};",
1601 qn(module, table),
1602 qi(old_col),
1603 qi(new_col)
1604 ),
1605 );
1606 }
1607
1608 apply_renames(&mut modified, type_renames, col_renames);
1610
1611 let mut fill_index: HashMap<(String, String), HashSet<String>> = HashMap::new();
1613 for (module, table, col, _) in fills {
1614 fill_index
1615 .entry((module.clone(), table.clone()))
1616 .or_default()
1617 .insert(col.clone());
1618 }
1619
1620 let mut diff_ops = flatten_ops(diff_inner(target, &modified, true, &fill_index)?);
1622 ops.append(&mut diff_ops);
1623
1624 for (module, table, col, fill_expr) in fills {
1626 push_tx(
1627 &mut ops,
1628 format!(
1629 "UPDATE {} SET {} = {} WHERE {} IS NULL;",
1630 qn(module, table),
1631 qi(col),
1632 fill_expr,
1633 qi(col)
1634 ),
1635 );
1636 push_tx(
1637 &mut ops,
1638 format!(
1639 "ALTER TABLE {} ALTER COLUMN {} SET NOT NULL;",
1640 qn(module, table),
1641 qi(col)
1642 ),
1643 );
1644 }
1645
1646 Ok(ops)
1647}
1648
1649pub fn diff_schema_steps_with_renames_and_fills(
1658 target: &SchemaDescriptor,
1659 current: &DbState,
1660 type_renames: &[(String, String, String, String)],
1661 col_renames: &[(String, String, String, String)],
1662 fills: &[(String, String, String, String)],
1663) -> Result<Vec<MigrationStep>, String> {
1664 let mut modified = current.clone();
1665 apply_renames(&mut modified, type_renames, col_renames);
1666
1667 let mut fill_index: HashMap<(String, String), HashSet<String>> = HashMap::new();
1668 for (module, table, col, _) in fills {
1669 fill_index
1670 .entry((module.clone(), table.clone()))
1671 .or_default()
1672 .insert(col.clone());
1673 }
1674
1675 let mut steps = diff_inner(target, &modified, true, &fill_index)?;
1676
1677 for (module, table, col, fill_expr) in fills {
1678 let fill_ops = vec![
1679 DiffOp {
1680 sql: format!(
1681 "UPDATE {} SET {} = {} WHERE {} IS NULL;",
1682 qn(module, table),
1683 qi(col),
1684 fill_expr,
1685 qi(col)
1686 ),
1687 non_transactional: false,
1688 },
1689 DiffOp {
1690 sql: format!(
1691 "ALTER TABLE {} ALTER COLUMN {} SET NOT NULL;",
1692 qn(module, table),
1693 qi(col)
1694 ),
1695 non_transactional: false,
1696 },
1697 ];
1698 match steps
1699 .iter_mut()
1700 .find(|s| matches!(&s.op_key, OpKey::Table(m, t) if m == module && t == table))
1701 {
1702 Some(step) => step.ddl.extend(fill_ops),
1703 None => steps.push(MigrationStep {
1704 prompt: format!("did you {} {}?", Verb::Alter.as_str(), verbosename_type(module, table)),
1705 verb: Verb::Alter,
1706 object_desc: verbosename_type(module, table),
1707 ddl: fill_ops,
1708 required_input: vec![],
1709 op_key: OpKey::Table(module.clone(), table.clone()),
1710 }),
1711 }
1712 }
1713
1714 Ok(steps)
1715}
1716
1717fn qi(s: &str) -> String {
1720 format!("\"{}\"", s.replace('"', "\"\""))
1721}
1722
1723fn pg_schema(module: &str) -> String {
1724 if module == "default" {
1725 "\"public\"".into()
1726 } else {
1727 qi(module)
1728 }
1729}
1730
1731fn enum_target(schema: &SchemaDescriptor, pg_type: &str) -> bool {
1735 let bare = pg_type.strip_suffix("[]").unwrap_or(pg_type);
1736 schema
1737 .enums
1738 .iter()
1739 .any(|e| bare == qn(&e.module, &e.name) || bare == qi(&e.name))
1740}
1741
1742fn qn(schema: &str, name: &str) -> String {
1743 format!("{}.{}", pg_schema(schema), qi(name))
1744}
1745
1746fn topo_sort_types(types: &[TypeDescriptor], polymorphic: &HashSet<String>) -> Vec<usize> {
1762 let idx_of: HashMap<String, usize> = types
1763 .iter()
1764 .enumerate()
1765 .map(|(i, t)| (format!("{}::{}", t.module, t.name), i))
1766 .collect();
1767
1768 let mut colour = vec![0u8; types.len()];
1770 let mut order: Vec<usize> = Vec::with_capacity(types.len());
1771
1772 fn visit(
1773 i: usize,
1774 types: &[TypeDescriptor],
1775 idx_of: &HashMap<String, usize>,
1776 polymorphic: &HashSet<String>,
1777 colour: &mut Vec<u8>,
1778 order: &mut Vec<usize>,
1779 ) {
1780 if colour[i] != 0 {
1781 return; }
1783 colour[i] = 1;
1784 for l in &types[i].links {
1788 if l.is_junction_backed() || polymorphic.contains(&l.target) {
1789 continue;
1790 }
1791 if let Some(&dep) = idx_of.get(&l.target) {
1792 visit(dep, types, idx_of, polymorphic, colour, order);
1793 }
1794 }
1795 colour[i] = 2;
1796 order.push(i);
1797 }
1798
1799 for i in 0..types.len() {
1800 visit(i, types, &idx_of, polymorphic, &mut colour, &mut order);
1801 }
1802 order
1803}
1804
1805fn col_type_str(p: &crate::schema::PropertyDescriptor) -> &str {
1809 p.column_type.as_deref().unwrap_or_else(|| resolved_pg_type(&p.pg_type))
1810}
1811
1812fn canonical_pg_type(pg_type: &str) -> String {
1818 if let Some(elem) = pg_type.strip_suffix("[]") {
1819 return format!("{}[]", canonical_pg_type(elem));
1820 }
1821 match pg_type {
1822 "int2" => "smallint",
1823 "int4" => "integer",
1824 "int8" => "bigint",
1825 "float4" => "real",
1826 "float8" => "double precision",
1827 "timestamptz" => "timestamp with time zone",
1828 "timestamp" => "timestamp without time zone",
1829 "time" => "time without time zone",
1830 other => other,
1831 }
1832 .to_string()
1833}
1834
1835fn bare_type_name(pg_type: &str) -> &str {
1845 pg_type.rsplit('.').next().unwrap_or(pg_type).trim_matches('"')
1846}
1847
1848fn pg_type_changed(target: &str, current: &str) -> bool {
1853 if target.starts_with('"') {
1854 bare_type_name(target) != bare_type_name(current)
1855 } else {
1856 canonical_pg_type(target) != canonical_pg_type(current)
1857 }
1858}
1859
1860fn diff_inner(
1863 target: &SchemaDescriptor,
1864 current: &DbState,
1865 for_migration: bool,
1866 fill_index: &HashMap<(String, String), HashSet<String>>,
1867) -> Result<Vec<MigrationStep>, String> {
1868 let mut steps = StepBuilder::new();
1869
1870 let cur_schemas: HashSet<&str> = current.schemas.iter().map(|s| s.as_str()).collect();
1871 let cur_tables: HashMap<(&str, &str), &DbTable> = current
1872 .tables
1873 .iter()
1874 .map(|t| ((t.schema.as_str(), t.name.as_str()), t))
1875 .collect();
1876 let cur_enums: HashMap<(&str, &str), &DbEnum> = current
1877 .enums
1878 .iter()
1879 .map(|e| ((e.schema.as_str(), e.name.as_str()), e))
1880 .collect();
1881 let cur_domains: HashSet<(&str, &str)> = current
1882 .domains
1883 .iter()
1884 .map(|d| (d.schema.as_str(), d.name.as_str()))
1885 .collect();
1886 let cur_sequences: HashSet<(&str, &str)> = current
1887 .sequences
1888 .iter()
1889 .map(|s| (s.schema.as_str(), s.name.as_str()))
1890 .collect();
1891 let cur_views: HashMap<(&str, &str), &str> = current
1892 .views
1893 .iter()
1894 .map(|v| ((v.schema.as_str(), v.name.as_str()), v.body_hash.as_str()))
1895 .collect();
1896 let cur_functions: HashMap<(&str, &str), &str> = current
1897 .functions
1898 .iter()
1899 .map(|f| ((f.schema.as_str(), f.name.as_str()), f.body_hash.as_str()))
1900 .collect();
1901
1902 let type_map: HashMap<String, (&str, &str)> = target
1903 .types
1904 .iter()
1905 .map(|t| {
1906 (
1907 format!("{}::{}", t.module, t.name),
1908 (t.module.as_str(), t.table.as_str()),
1909 )
1910 })
1911 .collect();
1912 let polymorphic = crate::export::polymorphic_types(target);
1913
1914 let mut target_schemas: HashSet<String> = HashSet::new();
1920 for t in &target.types {
1921 target_schemas.insert(t.module.clone());
1922 }
1923 for e in &target.enums {
1924 target_schemas.insert(e.module.clone());
1925 }
1926 for s in &target.scalars {
1927 target_schemas.insert(s.module.clone());
1928 }
1929 for f in &target.functions {
1930 target_schemas.insert(f.module.clone());
1931 }
1932 for g in &target.globals {
1933 target_schemas.insert(g.module.clone());
1934 }
1935 for a in &target.aliases {
1936 target_schemas.insert(a.module.clone());
1937 }
1938
1939 for module in &target_schemas {
1941 if module == "default" {
1942 continue;
1943 } if !cur_schemas.contains(module.as_str()) {
1945 steps.push(
1946 OpKey::Module(module.clone()),
1947 Verb::Create,
1948 verbosename_module(module),
1949 DiffOp {
1950 sql: format!("CREATE SCHEMA IF NOT EXISTS {};", pg_schema(module)),
1951 non_transactional: false,
1952 },
1953 );
1954 }
1955 }
1956
1957 for e in &target.enums {
1960 match cur_enums.get(&(e.module.as_str(), e.name.as_str())) {
1961 None => {
1962 let members: Vec<String> = e
1963 .members
1964 .iter()
1965 .map(|m| format!("'{}'", m.replace('\'', "''")))
1966 .collect();
1967 steps.push(
1968 OpKey::Scalar(e.module.clone(), e.name.clone()),
1969 Verb::Create,
1970 verbosename_scalar(&e.module, &e.name),
1971 DiffOp {
1972 sql: format!(
1973 "DO $$ BEGIN CREATE TYPE {}.{} AS ENUM ({}); \
1974 EXCEPTION WHEN duplicate_object THEN NULL; END $$;",
1975 pg_schema(&e.module),
1976 qi(&e.name),
1977 members.join(", ")
1978 ),
1979 non_transactional: false,
1980 },
1981 );
1982 }
1983 Some(existing) => {
1984 let existing_set: HashSet<&str> = existing.members.iter().map(|m| m.as_str()).collect();
1985 for member in &e.members {
1986 if !existing_set.contains(member.as_str()) {
1987 steps.push(
1988 OpKey::Scalar(e.module.clone(), e.name.clone()),
1989 Verb::Alter,
1990 verbosename_scalar(&e.module, &e.name),
1991 DiffOp {
1992 sql: format!(
1993 "ALTER TYPE {}.{} ADD VALUE IF NOT EXISTS '{}';",
1994 pg_schema(&e.module),
1995 qi(&e.name),
1996 member.replace('\'', "''")
1997 ),
1998 non_transactional: false,
1999 },
2000 );
2001 }
2002 }
2003 }
2004 }
2005 }
2006
2007 for s in &target.scalars {
2010 if s.is_sequence {
2011 let seq_name = format!("{}_seq", s.name);
2012 if !cur_sequences.contains(&(s.module.as_str(), seq_name.as_str())) {
2013 let verb = if cur_domains.contains(&(s.module.as_str(), s.name.as_str())) {
2014 Verb::Alter
2015 } else {
2016 Verb::Create
2017 };
2018 steps.push(
2019 OpKey::Scalar(s.module.clone(), s.name.clone()),
2020 verb,
2021 verbosename_scalar(&s.module, &s.name),
2022 DiffOp {
2023 sql: format!(
2024 "CREATE SEQUENCE IF NOT EXISTS {}.{};",
2025 pg_schema(&s.module),
2026 qi(&seq_name)
2027 ),
2028 non_transactional: false,
2029 },
2030 );
2031 }
2032 }
2033 }
2034
2035 for s in &target.scalars {
2037 if !cur_domains.contains(&(s.module.as_str(), s.name.as_str())) {
2038 let check_clause = crate::export::scalar_check_clauses(target, &s.module, &s.name);
2039 steps.push(
2040 OpKey::Scalar(s.module.clone(), s.name.clone()),
2041 Verb::Create,
2042 verbosename_scalar(&s.module, &s.name),
2043 DiffOp {
2044 sql: format!(
2045 "DO $do$ BEGIN CREATE DOMAIN {}.{} AS {}{}; \
2046 EXCEPTION WHEN duplicate_object THEN NULL; END $do$;",
2047 pg_schema(&s.module),
2048 qi(&s.name),
2049 s.pg_type,
2050 check_clause
2051 ),
2052 non_transactional: false,
2053 },
2054 );
2055 }
2056 }
2057
2058 let expected_domain_checks = crate::export::scalar_check_constraints(target);
2064 for cur_domain in ¤t.domains {
2065 if !target
2066 .scalars
2067 .iter()
2068 .any(|s| s.module == cur_domain.schema && s.name == cur_domain.name)
2069 {
2070 continue; }
2072 let wanted: Vec<&(String, String, String, String)> = expected_domain_checks
2073 .iter()
2074 .filter(|(module, name, _, _)| *module == cur_domain.schema && *name == cur_domain.name)
2075 .collect();
2076 let domain = format!("{}.{}", pg_schema(&cur_domain.schema), qi(&cur_domain.name));
2077 let unchanged = wanted.len() == cur_domain.checks.len()
2078 && wanted
2079 .iter()
2080 .all(|(_, _, cname, _)| cur_domain.checks.iter().any(|name| name == cname));
2081 if unchanged {
2082 continue;
2083 }
2084 let mut local: Vec<DiffOp> = Vec::new();
2085 push_tx(
2090 &mut local,
2091 format!(
2092 "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$;"
2093 ),
2094 );
2095 for (_, _, cname, expr) in wanted {
2096 push_tx(
2097 &mut local,
2098 format!("ALTER DOMAIN {} ADD CONSTRAINT {} CHECK ({});", domain, qi(cname), expr),
2099 );
2100 }
2101 if !local.is_empty() {
2102 steps.extend(
2103 OpKey::Scalar(cur_domain.schema.clone(), cur_domain.name.clone()),
2104 Verb::Alter,
2105 verbosename_scalar(&cur_domain.schema, &cur_domain.name),
2106 local,
2107 );
2108 }
2109 }
2110
2111 let scalar_fn_ddls = crate::export::scalar_function_ddl_with_names(target).map_err(|e| e.to_string())?;
2113 for (module, name, ddl) in scalar_fn_ddls {
2114 let emit = if for_migration {
2115 let hash = ddl_hash(&ddl);
2116 cur_functions
2117 .get(&(module.as_str(), name.as_str()))
2118 .map(|&h| h != hash)
2119 .unwrap_or(true)
2120 } else {
2121 true
2122 };
2123 if emit {
2124 let verb = if cur_functions.contains_key(&(module.as_str(), name.as_str())) {
2125 Verb::Alter
2126 } else {
2127 Verb::Create
2128 };
2129 steps.push(
2130 OpKey::Function(module.clone(), name.clone()),
2131 verb,
2132 verbosename_function(&module, &name),
2133 DiffOp {
2134 sql: ddl,
2135 non_transactional: false,
2136 },
2137 );
2138 }
2139 }
2140
2141 let sort_order = topo_sort_types(&target.types, &polymorphic);
2143
2144 let mut new_tables: HashSet<(String, String)> = HashSet::new();
2146
2147 for &i in &sort_order {
2148 let td = &target.types[i];
2149 if td.abstract_ || td.junction {
2150 continue;
2151 }
2152 let key = (td.module.as_str(), td.table.as_str());
2153 match cur_tables.get(&key) {
2154 None => {
2155 let mut local: Vec<DiffOp> = Vec::new();
2156 emit_create_table(td, target, &mut local);
2157 steps.extend(
2158 OpKey::Table(td.module.clone(), td.table.clone()),
2159 Verb::Create,
2160 verbosename_type(&td.module, &td.name),
2161 local,
2162 );
2163 new_tables.insert((td.module.clone(), td.table.clone()));
2164 }
2165 Some(existing) => {
2166 let fill_cols = fill_index
2167 .get(&(td.module.clone(), td.table.clone()))
2168 .cloned()
2169 .unwrap_or_default();
2170 let mut local: Vec<DiffOp> = Vec::new();
2171 let mut inputs: Vec<RequiredInput> = Vec::new();
2172 emit_column_diff(td, existing, &mut local, for_migration, &fill_cols, target, &mut inputs);
2173 steps.extend_with_input(
2174 OpKey::Table(td.module.clone(), td.table.clone()),
2175 Verb::Alter,
2176 verbosename_type(&td.module, &td.name),
2177 local,
2178 inputs,
2179 );
2180 }
2181 }
2182 }
2183
2184 for &i in &sort_order {
2191 let td = &target.types[i];
2192 if td.abstract_ || td.junction {
2193 continue;
2194 }
2195 let existing = cur_tables.get(&(td.module.as_str(), td.table.as_str())).copied();
2196 let verb = if new_tables.contains(&(td.module.clone(), td.table.clone())) {
2197 Verb::Create
2198 } else {
2199 Verb::Alter
2200 };
2201 let mut local: Vec<DiffOp> = Vec::new();
2202 emit_fk_diff(td, existing, &type_map, &polymorphic, &mut local);
2203 steps.extend(
2204 OpKey::ForeignKey(td.module.clone(), td.table.clone()),
2205 verb,
2206 verbosename_type(&td.module, &td.name),
2207 local,
2208 );
2209 }
2210
2211 for (jt_module, jt_name, cname, ddl) in crate::export::junction_fk_constraints(target, &type_map) {
2216 let already_there = cur_tables
2217 .get(&(jt_module.as_str(), jt_name.as_str()))
2218 .map(|t| t.foreign_keys.iter().any(|fk| fk.constraint_name == cname))
2219 .unwrap_or(false);
2220 if already_there {
2221 continue;
2222 }
2223 let verb = if cur_tables.contains_key(&(jt_module.as_str(), jt_name.as_str())) {
2224 Verb::Alter
2225 } else {
2226 Verb::Create
2227 };
2228 let mut local: Vec<DiffOp> = Vec::new();
2229 push_tx(&mut local, ddl);
2230 steps.extend(
2231 OpKey::ForeignKey(jt_module.clone(), jt_name.clone()),
2232 verb,
2233 format!("link table '{}.{}'", jt_module, jt_name),
2234 local,
2235 );
2236 }
2237
2238 for (module, table, cname, ddl) in crate::export::check_constraints(target).map_err(|e| e.to_string())? {
2245 let already_there = cur_tables
2246 .get(&(module.as_str(), table.as_str()))
2247 .map(|t| t.checks.iter().any(|c| c.constraint_name == cname))
2248 .unwrap_or(false);
2249 if already_there {
2250 continue;
2251 }
2252 let verb = if cur_tables.contains_key(&(module.as_str(), table.as_str())) {
2253 Verb::Alter
2254 } else {
2255 Verb::Create
2256 };
2257 let mut local: Vec<DiffOp> = Vec::new();
2258 push_tx(&mut local, ddl);
2259 steps.extend(
2260 OpKey::ForeignKey(module.clone(), format!("{table}#checks")),
2261 verb,
2262 verbosename_type(&module, &table),
2263 local,
2264 );
2265 }
2266
2267 for &i in &sort_order {
2273 let td = &target.types[i];
2274 if td.abstract_ || td.junction {
2275 continue;
2276 }
2277 let owner_key = OpKey::Table(td.module.clone(), td.table.clone());
2278 let owner_verb = if new_tables.contains(&(td.module.clone(), td.table.clone())) {
2279 Verb::Create
2280 } else {
2281 Verb::Alter
2282 };
2283 let owner_desc = verbosename_type(&td.module, &td.name);
2284 for ml in &td.multilinks {
2285 let jt = format!("{}.{}", td.table, ml.name);
2286 if !cur_tables.contains_key(&(td.module.as_str(), jt.as_str())) {
2287 let mut local: Vec<DiffOp> = Vec::new();
2288 emit_junction_table(td, &ml.name, ml.through.as_deref(), target, false, &mut local);
2289 steps.extend(owner_key.clone(), owner_verb, owner_desc.clone(), local);
2290 new_tables.insert((td.module.clone(), jt));
2291 }
2292 }
2293 for l in &td.links {
2294 if !l.is_junction_backed() {
2295 continue;
2296 }
2297 let jt = format!("{}.{}", td.table, l.name);
2298 if !cur_tables.contains_key(&(td.module.as_str(), jt.as_str())) {
2299 let mut local: Vec<DiffOp> = Vec::new();
2300 emit_junction_table(td, &l.name, l.through.as_deref(), target, true, &mut local);
2301 steps.extend(owner_key.clone(), owner_verb, owner_desc.clone(), local);
2302 new_tables.insert((td.module.clone(), jt));
2303 }
2304 }
2305 }
2306
2307 for &i in &sort_order {
2309 let td = &target.types[i];
2310 if td.abstract_ || td.vector_indexes.is_empty() {
2311 continue;
2312 }
2313 let existing = cur_tables.get(&(td.module.as_str(), td.table.as_str()));
2314 let table_is_new = new_tables.contains(&(td.module.clone(), td.table.clone()));
2315 let owner_key = OpKey::Table(td.module.clone(), td.table.clone());
2316 let owner_verb = if table_is_new { Verb::Create } else { Verb::Alter };
2317 let owner_desc = verbosename_type(&td.module, &td.name);
2318
2319 for vi in &td.vector_indexes {
2320 let col = vi.column_name();
2321 if existing
2322 .map(|t| t.columns.iter().any(|c| c.name == col))
2323 .unwrap_or(false)
2324 {
2325 continue;
2326 }
2327 let mut local: Vec<DiffOp> = Vec::new();
2328 push_tx(
2329 &mut local,
2330 format!(
2331 "ALTER TABLE {} ADD COLUMN IF NOT EXISTS {} vector({});",
2332 qn(&td.module, &td.table),
2333 qi(&col),
2334 vi.dimensions
2335 ),
2336 );
2337 let idx_name = match &vi.index_name {
2338 None => format!("{}__vector__", td.table),
2339 Some(n) => format!("{}__vector_{}__", td.table, n),
2340 };
2341 let use_concurrently = for_migration && !table_is_new;
2344 let idx_sql = if use_concurrently {
2345 format!(
2346 "CREATE INDEX CONCURRENTLY IF NOT EXISTS {} ON {} USING hnsw ({} {});",
2347 qi(&idx_name),
2348 qn(&td.module, &td.table),
2349 qi(&col),
2350 vi.ops_class()
2351 )
2352 } else {
2353 format!(
2354 "CREATE INDEX IF NOT EXISTS {} ON {} USING hnsw ({} {});",
2355 qi(&idx_name),
2356 qn(&td.module, &td.table),
2357 qi(&col),
2358 vi.ops_class()
2359 )
2360 };
2361 local.push(DiffOp {
2362 sql: idx_sql,
2363 non_transactional: use_concurrently,
2364 });
2365 steps.extend(owner_key.clone(), owner_verb, owner_desc.clone(), local);
2366 }
2367 }
2368
2369 for &i in &sort_order {
2371 let td = &target.types[i];
2372 if td.abstract_ || td.search_indexes.is_empty() {
2373 continue;
2374 }
2375 let existing = cur_tables.get(&(td.module.as_str(), td.table.as_str()));
2376 let table_is_new = new_tables.contains(&(td.module.clone(), td.table.clone()));
2377 let owner_key = OpKey::Table(td.module.clone(), td.table.clone());
2378 let owner_verb = if table_is_new { Verb::Create } else { Verb::Alter };
2379 let owner_desc = verbosename_type(&td.module, &td.name);
2380
2381 for si in &td.search_indexes {
2382 if si.backend != SearchBackend::Postgres {
2383 continue;
2384 }
2385 let col = si.column_name();
2386 if existing
2387 .map(|t| t.columns.iter().any(|c| c.name == col))
2388 .unwrap_or(false)
2389 {
2390 continue;
2391 }
2392 let mut local: Vec<DiffOp> = Vec::new();
2393 let parts: Vec<String> = si
2394 .pointers
2395 .iter()
2396 .map(|sf| {
2397 format!(
2398 "setweight(to_tsvector('english', coalesce({}, '')), '{}')",
2399 qi(&sf.name),
2400 sf.weight.as_str()
2401 )
2402 })
2403 .collect();
2404 let expr = if parts.len() == 1 {
2405 parts.into_iter().next().unwrap()
2406 } else {
2407 parts.join(" || ")
2408 };
2409 push_tx(
2410 &mut local,
2411 format!(
2412 "ALTER TABLE {} ADD COLUMN IF NOT EXISTS {} tsvector GENERATED ALWAYS AS ({}) STORED;",
2413 qn(&td.module, &td.table),
2414 qi(&col),
2415 expr
2416 ),
2417 );
2418 let idx_name = match &si.index_name {
2419 None => format!("{}__search__", td.table),
2420 Some(n) => format!("{}__search_{}__", td.table, n),
2421 };
2422 let use_concurrently = for_migration && !table_is_new;
2423 let idx_sql = if use_concurrently {
2424 format!(
2425 "CREATE INDEX CONCURRENTLY IF NOT EXISTS {} ON {} USING gin ({});",
2426 qi(&idx_name),
2427 qn(&td.module, &td.table),
2428 qi(&col)
2429 )
2430 } else {
2431 format!(
2432 "CREATE INDEX IF NOT EXISTS {} ON {} USING gin ({});",
2433 qi(&idx_name),
2434 qn(&td.module, &td.table),
2435 qi(&col)
2436 )
2437 };
2438 local.push(DiffOp {
2439 sql: idx_sql,
2440 non_transactional: use_concurrently,
2441 });
2442 steps.extend(owner_key.clone(), owner_verb, owner_desc.clone(), local);
2443 }
2444 }
2445
2446 let expected_state = schema_to_db_state(target);
2452 let current_indexes: HashMap<(&str, &str), &[DbIndex]> = current
2453 .tables
2454 .iter()
2455 .map(|t| ((t.schema.as_str(), t.name.as_str()), t.indexes.as_slice()))
2456 .collect();
2457 for table in &expected_state.tables {
2458 let existing = current_indexes
2459 .get(&(table.schema.as_str(), table.name.as_str()))
2460 .copied()
2461 .unwrap_or_default();
2462 let table_is_new = !current_indexes.contains_key(&(table.schema.as_str(), table.name.as_str()));
2463 for idx in &table.indexes {
2464 if idx.columns.is_empty() && idx.key.is_none() {
2467 continue;
2468 }
2469 let already_there = match &idx.key {
2475 Some(_) => existing.iter().any(|b| b.name == idx.name),
2476 None => existing.iter().any(|b| {
2477 b.columns == idx.columns
2478 && b.is_unique == idx.is_unique
2479 && b.predicate.is_some() == idx.predicate.is_some()
2480 }),
2481 };
2482 if already_there {
2483 continue;
2484 }
2485 let use_concurrently = for_migration && !table_is_new;
2486 let concurrently = if use_concurrently { "CONCURRENTLY " } else { "" };
2487 let unique = if idx.is_unique { "UNIQUE " } else { "" };
2488 let body = match &idx.key {
2489 Some(key) => key.clone(),
2490 None => format!("({})", idx.columns.iter().map(|c| qi(c)).collect::<Vec<_>>().join(", ")),
2491 };
2492 let sql = format!(
2493 "CREATE {unique}INDEX {concurrently}IF NOT EXISTS {} ON {} {}{};",
2494 qi(&capped_index_name(idx.name.clone())),
2495 qn(&table.schema, &table.name),
2496 body,
2497 idx.predicate.clone().unwrap_or_default(),
2498 );
2499 let owner_key = OpKey::Table(table.schema.clone(), table.name.clone());
2500 let owner_verb = if table_is_new { Verb::Create } else { Verb::Alter };
2501 let owner_desc = verbosename_type(&table.schema, &table.name);
2502 steps.extend(
2503 owner_key,
2504 owner_verb,
2505 owner_desc,
2506 vec![DiffOp {
2507 sql,
2508 non_transactional: use_concurrently,
2509 }],
2510 );
2511 }
2512 }
2513
2514 for (module, name, ddl) in crate::export::interface_view_ddl_with_names(target) {
2518 let emit = if for_migration {
2519 let hash = ddl_hash(&ddl);
2520 cur_views
2521 .get(&(module.as_str(), name.as_str()))
2522 .map(|&h| h != hash)
2523 .unwrap_or(true)
2524 } else {
2525 true
2526 };
2527 if emit {
2528 let verb = if cur_views.contains_key(&(module.as_str(), name.as_str())) {
2529 Verb::Alter
2530 } else {
2531 Verb::Create
2532 };
2533 steps.push(
2534 OpKey::View(module.clone(), name.clone()),
2535 verb,
2536 verbosename_interface(&module, &name),
2537 DiffOp {
2538 sql: ddl,
2539 non_transactional: false,
2540 },
2541 );
2542 }
2543 }
2544
2545 for (module, name, ddl) in crate::export::interface_junction_view_ddl_with_names(target) {
2553 let emit = if for_migration {
2554 let hash = ddl_hash(&ddl);
2555 cur_views
2556 .get(&(module.as_str(), name.as_str()))
2557 .map(|&h| h != hash)
2558 .unwrap_or(true)
2559 } else {
2560 true
2561 };
2562 if emit {
2563 let verb = if cur_views.contains_key(&(module.as_str(), name.as_str())) {
2564 Verb::Alter
2565 } else {
2566 Verb::Create
2567 };
2568 steps.push(
2569 OpKey::View(module.clone(), name.clone()),
2570 verb,
2571 verbosename_interface(&module, &name),
2572 DiffOp {
2573 sql: ddl,
2574 non_transactional: false,
2575 },
2576 );
2577 }
2578 }
2579
2580 let target_views: HashSet<(String, String)> = crate::export::interface_view_ddl_with_names(target)
2585 .into_iter()
2586 .chain(crate::export::interface_junction_view_ddl_with_names(target))
2587 .map(|(module, name, _)| (module, name))
2588 .collect();
2589 for view in ¤t.views {
2590 if !target_views.contains(&(view.schema.clone(), view.name.clone())) {
2591 steps.push(
2592 OpKey::View(view.schema.clone(), view.name.clone()),
2593 Verb::Drop,
2594 verbosename_interface(&view.schema, &view.name),
2595 DiffOp {
2596 sql: format!("DROP VIEW IF EXISTS {};", qn(&view.schema, &view.name)),
2597 non_transactional: false,
2598 },
2599 );
2600 }
2601 }
2602
2603 let obj_fn_ddls = crate::export::object_function_ddl_with_names(target).map_err(|e| e.to_string())?;
2605 for (module, name, ddl) in obj_fn_ddls {
2606 let emit = if for_migration {
2607 let hash = ddl_hash(&ddl);
2608 cur_functions
2609 .get(&(module.as_str(), name.as_str()))
2610 .map(|&h| h != hash)
2611 .unwrap_or(true)
2612 } else {
2613 true
2614 };
2615 if emit {
2616 let verb = if cur_functions.contains_key(&(module.as_str(), name.as_str())) {
2617 Verb::Alter
2618 } else {
2619 Verb::Create
2620 };
2621 steps.push(
2622 OpKey::Function(module.clone(), name.clone()),
2623 verb,
2624 verbosename_function(&module, &name),
2625 DiffOp {
2626 sql: ddl,
2627 non_transactional: false,
2628 },
2629 );
2630 }
2631 }
2632
2633 let mut target_tables: HashSet<(String, String)> = HashSet::new();
2638 for td in &target.types {
2639 if !td.abstract_ {
2640 target_tables.insert((td.module.clone(), td.table.clone()));
2641 }
2642 if !td.abstract_ && !td.junction {
2643 for ml in &td.multilinks {
2644 target_tables.insert((td.module.clone(), format!("{}.{}", td.table, ml.name)));
2645 }
2646 for l in &td.links {
2647 if !l.is_junction_backed() {
2648 continue;
2649 }
2650 target_tables.insert((td.module.clone(), format!("{}.{}", td.table, l.name)));
2651 }
2652 }
2653 }
2654
2655 {
2659 let infos = crate::export::interface_exclusive_trigger_infos(target);
2660 let cur_trigger_map: HashMap<(&str, &str), HashSet<&str>> = current
2661 .tables
2662 .iter()
2663 .map(|t| {
2664 (
2665 (t.schema.as_str(), t.name.as_str()),
2666 t.triggers.iter().map(|n| n.as_str()).collect::<HashSet<_>>(),
2667 )
2668 })
2669 .collect();
2670
2671 let expected_trigger_map = expected_triggers(target, &type_map);
2677
2678 let owner_of = |module: &str, table: &str| -> (OpKey, Verb, String) {
2683 for &i in &sort_order {
2684 let td = &target.types[i];
2685 if td.abstract_ || td.junction {
2686 continue;
2687 }
2688 let is_owner = td.module == module
2689 && (td.table == table
2690 || td
2691 .multilinks
2692 .iter()
2693 .any(|ml| format!("{}.{}", td.table, ml.name) == table)
2694 || td
2695 .links
2696 .iter()
2697 .any(|l| l.is_junction_backed() && format!("{}.{}", td.table, l.name) == table));
2698 if is_owner {
2699 let verb = if new_tables.contains(&(td.module.clone(), td.table.clone())) {
2700 Verb::Create
2701 } else {
2702 Verb::Alter
2703 };
2704 return (
2705 OpKey::Table(td.module.clone(), td.table.clone()),
2706 verb,
2707 verbosename_type(&td.module, &td.name),
2708 );
2709 }
2710 }
2711 (
2712 OpKey::Table(module.to_string(), table.to_string()),
2713 Verb::Alter,
2714 verbosename_type(module, table),
2715 )
2716 };
2717
2718 struct PendingExclTrigger<'a> {
2719 info: &'a crate::export::ExclTriggerInfo,
2720 need_ins: bool,
2721 need_upd: bool,
2722 owner: (OpKey, Verb, String),
2723 }
2724
2725 let pending: Vec<PendingExclTrigger<'_>> = infos
2726 .iter()
2727 .filter_map(|info| {
2728 let cur = cur_trigger_map
2729 .get(&(info.impl_module.as_str(), info.impl_table.as_str()))
2730 .cloned()
2731 .unwrap_or_default();
2732 let need_ins = !cur.contains(info.ins_trigger_name.as_str());
2733 let need_upd = !cur.contains(info.upd_trigger_name.as_str());
2734 (need_ins || need_upd).then(|| PendingExclTrigger {
2735 info,
2736 need_ins,
2737 need_upd,
2738 owner: owner_of(&info.impl_module, &info.impl_table),
2739 })
2740 })
2741 .collect();
2742
2743 let mut fn_carrier: HashMap<String, usize> = HashMap::new();
2752 for (index, entry) in pending.iter().enumerate() {
2753 let position = steps.position(&entry.owner.0).unwrap_or(usize::MAX);
2754 let beats_carrier = match fn_carrier.get(&entry.info.fn_name) {
2755 Some(&carrier) => position < steps.position(&pending[carrier].owner.0).unwrap_or(usize::MAX),
2756 None => true,
2757 };
2758 if beats_carrier {
2759 fn_carrier.insert(entry.info.fn_name.clone(), index);
2760 }
2761 }
2762
2763 for (index, entry) in pending.into_iter().enumerate() {
2764 let mut local: Vec<DiffOp> = Vec::new();
2765 if fn_carrier.get(&entry.info.fn_name) == Some(&index) {
2766 push_tx(&mut local, entry.info.fn_ddl.clone());
2767 }
2768 if entry.need_ins {
2769 push_tx(&mut local, entry.info.ins_ddl.clone());
2770 }
2771 if entry.need_upd {
2772 push_tx(&mut local, entry.info.upd_ddl.clone());
2773 }
2774 let (key, verb, desc) = entry.owner;
2775 steps.extend(key, verb, desc, local);
2776 }
2777
2778 for info in crate::export::deletion_policy_trigger_infos(target, &type_map) {
2786 let cur = cur_trigger_map
2787 .get(&(info.table_module.as_str(), info.table_name.as_str()))
2788 .cloned()
2789 .unwrap_or_default();
2790 if !cur.contains(info.trigger_name.as_str()) {
2791 let (key, verb, desc) = owner_of(&info.table_module, &info.table_name);
2792 steps.extend(
2793 key,
2794 verb,
2795 desc,
2796 vec![DiffOp {
2797 sql: info.ddl.clone(),
2798 non_transactional: false,
2799 }],
2800 );
2801 }
2802 }
2803
2804 for info in crate::export::signal_trigger_infos(target) {
2811 let cur = cur_trigger_map
2812 .get(&(info.table_module.as_str(), info.table_name.as_str()))
2813 .cloned()
2814 .unwrap_or_default();
2815 if !cur.contains(info.trigger_name.as_str()) {
2816 let (key, verb, desc) = owner_of(&info.table_module, &info.table_name);
2817 steps.extend(
2818 key,
2819 verb,
2820 desc,
2821 vec![DiffOp {
2822 sql: info.ddl.clone(),
2823 non_transactional: false,
2824 }],
2825 );
2826 }
2827 }
2828
2829 for info in crate::export::user_trigger_infos(target).map_err(|e| e.to_string())? {
2840 let cur = cur_trigger_map
2841 .get(&(info.table_module.as_str(), info.table_name.as_str()))
2842 .cloned()
2843 .unwrap_or_default();
2844 if !cur.contains(info.trigger_name.as_str()) {
2845 let (key, verb, desc) = owner_of(&info.table_module, &info.table_name);
2846 steps.extend(
2847 key,
2848 verb,
2849 desc,
2850 vec![DiffOp {
2851 sql: info.ddl.clone(),
2852 non_transactional: false,
2853 }],
2854 );
2855 }
2856 }
2857
2858 let mut cache_trigger_tables: HashSet<(String, String)> = HashSet::new();
2872 for td in &target.types {
2873 if td.abstract_ || junction_table_belongs_to_an_interface(target, td) {
2874 continue;
2875 }
2876 cache_trigger_tables.insert((td.module.clone(), td.table.clone()));
2877 if !td.junction {
2878 for ml in &td.multilinks {
2879 cache_trigger_tables.insert((td.module.clone(), format!("{}.{}", td.table, ml.name)));
2880 }
2881 for l in &td.links {
2882 if !l.is_junction_backed() {
2883 continue;
2884 }
2885 cache_trigger_tables.insert((td.module.clone(), format!("{}.{}", td.table, l.name)));
2886 }
2887 }
2888 }
2889 for (module, table) in &cache_trigger_tables {
2890 let already_present = cur_trigger_map
2891 .get(&(module.as_str(), table.as_str()))
2892 .map(|t| t.contains("pylon_cache_invalidate"))
2893 .unwrap_or(false);
2894 if !already_present {
2895 let (key, verb, desc) = owner_of(module, table);
2896 steps.extend(
2897 key,
2898 verb,
2899 desc,
2900 vec![DiffOp {
2901 sql: cache_invalidate_trigger_sql(&qn(module, table)),
2902 non_transactional: false,
2903 }],
2904 );
2905 }
2906 }
2907
2908 for cur_table in ¤t.tables {
2919 let key = (cur_table.schema.clone(), cur_table.name.clone());
2920 if !target_tables.contains(&key) {
2921 continue;
2922 }
2923 let expected = expected_trigger_map.get(&key).cloned().unwrap_or_default();
2924 for trigger_name in &cur_table.triggers {
2925 if !expected.contains(trigger_name) {
2926 let (owner_key, verb, desc) = owner_of(&cur_table.schema, &cur_table.name);
2927 steps.extend(
2928 owner_key,
2929 verb,
2930 desc,
2931 vec![
2932 DiffOp {
2933 sql: format!(
2934 "DROP TRIGGER IF EXISTS {} ON {};",
2935 qi(trigger_name),
2936 qn(&cur_table.schema, &cur_table.name)
2937 ),
2938 non_transactional: false,
2939 },
2940 DiffOp {
2958 sql: format!("DROP FUNCTION IF EXISTS {}();", qn(&cur_table.schema, trigger_name)),
2959 non_transactional: false,
2960 },
2961 ],
2962 );
2963 }
2964 }
2965 }
2966 }
2967
2968 let expected_checks: HashSet<(String, String, String)> = crate::export::check_constraints(target)
2975 .map_err(|e| e.to_string())?
2976 .into_iter()
2977 .map(|(module, table, name, _)| (module, table, name))
2978 .collect();
2979 for cur_table in ¤t.tables {
2980 if !target_tables.contains(&(cur_table.schema.clone(), cur_table.name.clone())) {
2981 continue; }
2983 for check in &cur_table.checks {
2984 if !is_generated_check_name(&check.constraint_name) {
2985 continue;
2986 }
2987 let key = (
2988 cur_table.schema.clone(),
2989 cur_table.name.clone(),
2990 check.constraint_name.clone(),
2991 );
2992 if expected_checks.contains(&key) {
2993 continue;
2994 }
2995 steps.extend(
2996 OpKey::ForeignKey(cur_table.schema.clone(), format!("{}#checks", cur_table.name)),
2997 Verb::Alter,
2998 verbosename_type(&cur_table.schema, &cur_table.name),
2999 vec![DiffOp {
3000 sql: format!(
3001 "ALTER TABLE {} DROP CONSTRAINT IF EXISTS {};",
3002 qn(&cur_table.schema, &cur_table.name),
3003 qi(&check.constraint_name)
3004 ),
3005 non_transactional: false,
3006 }],
3007 );
3008 }
3009 }
3010
3011 let polymorphic_targets = crate::export::polymorphic_types(target);
3014 let mut unwanted_fks: HashSet<(String, String, String)> = HashSet::new();
3015 for t in target.types.iter().filter(|t| !t.abstract_ && !t.junction) {
3016 for l in &t.links {
3017 if !polymorphic_targets.contains(&l.target) {
3018 continue;
3019 }
3020 unwanted_fks.insert(if l.is_junction_backed() {
3021 (
3022 t.module.clone(),
3023 format!("{}.{}", t.table, l.name),
3024 format!("{}_{}_target_fkey", t.table, l.name),
3025 )
3026 } else {
3027 (
3028 t.module.clone(),
3029 t.table.clone(),
3030 format!("{}_{}_fkey", t.table, l.name),
3031 )
3032 });
3033 }
3034 for ml in t
3035 .multilinks
3036 .iter()
3037 .filter(|ml| polymorphic_targets.contains(&ml.target))
3038 {
3039 unwanted_fks.insert((
3040 t.module.clone(),
3041 format!("{}.{}", t.table, ml.name),
3042 format!("{}_{}_target_fkey", t.table, ml.name),
3043 ));
3044 }
3045 }
3046 for cur_table in ¤t.tables {
3047 for fk in &cur_table.foreign_keys {
3048 let key = (
3049 cur_table.schema.clone(),
3050 cur_table.name.clone(),
3051 fk.constraint_name.clone(),
3052 );
3053 let references_a_table = target
3055 .types
3056 .iter()
3057 .any(|t| !t.abstract_ && t.module == fk.ref_schema && t.table == fk.ref_table);
3058 if !unwanted_fks.contains(&key) || !references_a_table {
3059 continue;
3060 }
3061 steps.extend(
3062 OpKey::ForeignKey(cur_table.schema.clone(), cur_table.name.clone()),
3063 Verb::Alter,
3064 verbosename_type(&cur_table.schema, &cur_table.name),
3065 vec![DiffOp {
3066 sql: format!(
3067 "ALTER TABLE {} DROP CONSTRAINT IF EXISTS {};",
3068 qn(&cur_table.schema, &cur_table.name),
3069 qi(&fk.constraint_name)
3070 ),
3071 non_transactional: false,
3072 }],
3073 );
3074 }
3075 }
3076
3077 for cur_table in ¤t.tables {
3079 let key = (cur_table.schema.clone(), cur_table.name.clone());
3080 if !target_tables.contains(&key) {
3081 steps.push(
3082 OpKey::Table(cur_table.schema.clone(), cur_table.name.clone()),
3083 Verb::Drop,
3084 verbosename_type(&cur_table.schema, &cur_table.name),
3085 DiffOp {
3086 sql: format!(
3087 "DROP TABLE IF EXISTS {} CASCADE;",
3088 qn(&cur_table.schema, &cur_table.name)
3089 ),
3090 non_transactional: false,
3091 },
3092 );
3093 }
3094 }
3095
3096 let target_enum_set: HashSet<(String, String)> = target
3098 .enums
3099 .iter()
3100 .map(|e| (e.module.clone(), e.name.clone()))
3101 .collect();
3102 for cur_enum in ¤t.enums {
3103 if !target_enum_set.contains(&(cur_enum.schema.clone(), cur_enum.name.clone())) {
3104 steps.push(
3105 OpKey::Scalar(cur_enum.schema.clone(), cur_enum.name.clone()),
3106 Verb::Drop,
3107 verbosename_scalar(&cur_enum.schema, &cur_enum.name),
3108 DiffOp {
3109 sql: format!(
3110 "DROP TYPE IF EXISTS {}.{} CASCADE;",
3111 pg_schema(&cur_enum.schema),
3112 qi(&cur_enum.name)
3113 ),
3114 non_transactional: false,
3115 },
3116 );
3117 }
3118 }
3119
3120 let target_domain_set: HashSet<(String, String)> = target
3122 .scalars
3123 .iter()
3124 .map(|s| (s.module.clone(), s.name.clone()))
3125 .collect();
3126 for cur_domain in ¤t.domains {
3127 if !target_domain_set.contains(&(cur_domain.schema.clone(), cur_domain.name.clone())) {
3128 steps.push(
3129 OpKey::Scalar(cur_domain.schema.clone(), cur_domain.name.clone()),
3130 Verb::Drop,
3131 verbosename_scalar(&cur_domain.schema, &cur_domain.name),
3132 DiffOp {
3133 sql: format!(
3134 "DROP DOMAIN IF EXISTS {}.{} CASCADE;",
3135 pg_schema(&cur_domain.schema),
3136 qi(&cur_domain.name)
3137 ),
3138 non_transactional: false,
3139 },
3140 );
3141 }
3142 }
3143
3144 let target_sequence_set: HashSet<(String, String)> = target
3147 .scalars
3148 .iter()
3149 .filter(|s| s.is_sequence)
3150 .map(|s| (s.module.clone(), format!("{}_seq", s.name)))
3151 .collect();
3152 for cur_seq in ¤t.sequences {
3153 if !target_sequence_set.contains(&(cur_seq.schema.clone(), cur_seq.name.clone())) {
3154 let scalar_name = cur_seq.name.strip_suffix("_seq").unwrap_or(&cur_seq.name).to_string();
3155 steps.push(
3156 OpKey::Scalar(cur_seq.schema.clone(), scalar_name.clone()),
3157 Verb::Drop,
3158 verbosename_scalar(&cur_seq.schema, &scalar_name),
3159 DiffOp {
3160 sql: format!(
3161 "DROP SEQUENCE IF EXISTS {}.{};",
3162 pg_schema(&cur_seq.schema),
3163 qi(&cur_seq.name)
3164 ),
3165 non_transactional: false,
3166 },
3167 );
3168 }
3169 }
3170
3171 for module in ¤t.schemas {
3173 if module == "default" {
3174 continue;
3175 } if !target_schemas.contains(module) {
3177 steps.push(
3178 OpKey::Module(module.clone()),
3179 Verb::Drop,
3180 verbosename_module(module),
3181 DiffOp {
3182 sql: format!("DROP SCHEMA IF EXISTS {} CASCADE;", pg_schema(module)),
3183 non_transactional: false,
3184 },
3185 );
3186 }
3187 }
3188
3189 Ok(steps.finish())
3190}
3191
3192fn push_tx(ops: &mut Vec<DiffOp>, sql: String) {
3193 ops.push(DiffOp {
3194 sql,
3195 non_transactional: false,
3196 });
3197}
3198
3199fn resolve_default(p: &crate::schema::PropertyDescriptor, schema: &SchemaDescriptor) -> Option<String> {
3205 if let Some(sql) = &p.default_sql {
3206 return Some(sql.clone());
3207 }
3208 if let Some(pyql) = &p.default_pyql {
3209 return crate::ir::column_default_sql(pyql, schema);
3210 }
3211 None
3212}
3213
3214#[cfg(test)]
3217pub(crate) fn resolve_default_for_test(
3218 p: &crate::schema::PropertyDescriptor,
3219 schema: &SchemaDescriptor,
3220) -> Option<String> {
3221 resolve_default(p, schema)
3222}
3223
3224fn resolve_link_default(l: &crate::schema::LinkDescriptor, schema: &SchemaDescriptor) -> Option<String> {
3225 if let Some(pyql) = &l.default_pyql {
3226 return crate::ir::column_default_sql(pyql, schema);
3227 }
3228 None
3229}
3230
3231fn emit_create_table(td: &TypeDescriptor, schema: &SchemaDescriptor, ops: &mut Vec<DiffOp>) {
3234 let mut lines: Vec<String> = Vec::new();
3235 for p in &td.properties {
3236 let not_null = if p.nullable { "" } else { " NOT NULL" };
3237 let default = resolve_default(p, schema)
3238 .map(|d| format!(" DEFAULT {}", d))
3239 .unwrap_or_default();
3240 lines.push(format!(
3241 " {} {}{}{}",
3242 qi(&p.name),
3243 col_type_str(p),
3244 not_null,
3245 default
3246 ));
3247 }
3248 for l in &td.links {
3249 if l.is_junction_backed() {
3250 continue;
3251 }
3252 let not_null = if l.nullable { "" } else { " NOT NULL" };
3253 let default = resolve_link_default(l, schema)
3254 .map(|d| format!(" DEFAULT {}", d))
3255 .unwrap_or_default();
3256 lines.push(format!(
3257 " {} uuid{}{}",
3258 qi(&format!("{}_id", l.name)),
3259 not_null,
3260 default
3261 ));
3262 }
3263 let pk_cols: Vec<String> = td.properties.iter().filter(|p| p.is_pk).map(|p| qi(&p.name)).collect();
3264 if !pk_cols.is_empty() {
3265 lines.push(format!(" PRIMARY KEY ({})", pk_cols.join(", ")));
3266 }
3267 push_tx(
3268 ops,
3269 format!(
3270 "CREATE TABLE IF NOT EXISTS {} (\n{}\n);",
3271 qn(&td.module, &td.table),
3272 lines.join(",\n")
3273 ),
3274 );
3275}
3276
3277fn cache_invalidate_trigger_sql(qualified_table: &str) -> String {
3282 format!(
3283 "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();",
3284 qualified_table
3285 )
3286}
3287
3288fn emit_column_diff(
3295 td: &TypeDescriptor,
3296 existing: &DbTable,
3297 ops: &mut Vec<DiffOp>,
3298 for_migration: bool,
3299 fill_cols: &HashSet<String>,
3300 schema: &SchemaDescriptor,
3301 required_input: &mut Vec<RequiredInput>,
3302) {
3303 let existing_col_map: HashMap<&str, &DbColumn> = existing.columns.iter().map(|c| (c.name.as_str(), c)).collect();
3304
3305 for p in &td.properties {
3307 if existing_col_map.contains_key(p.name.as_str()) {
3308 continue;
3309 }
3310 let eff_default = resolve_default(p, schema);
3311 let needs_fill = for_migration && !p.nullable && eff_default.is_none() && fill_cols.contains(&p.name);
3312 let not_null = if p.nullable || needs_fill { "" } else { " NOT NULL" };
3313 let default = eff_default.map(|d| format!(" DEFAULT {}", d)).unwrap_or_default();
3314 push_tx(
3315 ops,
3316 format!(
3317 "ALTER TABLE {} ADD COLUMN IF NOT EXISTS {} {}{}{};",
3318 qn(&td.module, &td.table),
3319 qi(&p.name),
3320 col_type_str(p),
3321 not_null,
3322 default
3323 ),
3324 );
3325 }
3326 for l in &td.links {
3327 if l.is_junction_backed() {
3328 continue;
3329 }
3330 let col = format!("{}_id", l.name);
3331 if existing_col_map.contains_key(col.as_str()) {
3332 continue;
3333 }
3334 let eff_default = resolve_link_default(l, schema);
3335 let needs_fill = for_migration && !l.nullable && eff_default.is_none() && fill_cols.contains(&col);
3336 let not_null = if l.nullable || needs_fill { "" } else { " NOT NULL" };
3337 let default = eff_default.map(|d| format!(" DEFAULT {}", d)).unwrap_or_default();
3338 push_tx(
3339 ops,
3340 format!(
3341 "ALTER TABLE {} ADD COLUMN IF NOT EXISTS {} uuid{}{};",
3342 qn(&td.module, &td.table),
3343 qi(&col),
3344 not_null,
3345 default
3346 ),
3347 );
3348 }
3349
3350 let type_changes: Vec<(&str, &str, bool)> = td
3354 .properties
3355 .iter()
3356 .filter_map(|p| {
3357 let cur = existing_col_map.get(p.name.as_str())?;
3358 if cur.is_generated {
3359 return None;
3360 }
3361 let target_type = col_type_str(p);
3362 pg_type_changed(target_type, &cur.pg_type).then_some((
3363 p.name.as_str(),
3364 target_type,
3365 cur.column_default.is_some(),
3366 ))
3367 })
3368 .collect();
3369 if !type_changes.is_empty() {
3370 let affected_views: Vec<(String, String, String)> = crate::export::interface_view_ddl_with_names(schema)
3375 .into_iter()
3376 .filter(|(m, n, _)| td.interfaces.contains(&format!("{}::{}", m, n)))
3377 .collect();
3378 for (m, n, _) in &affected_views {
3379 push_tx(ops, format!("DROP VIEW IF EXISTS {};", qn(m, n)));
3380 }
3381 for (col, target_type, has_default) in &type_changes {
3382 if *has_default {
3388 push_tx(
3389 ops,
3390 format!(
3391 "ALTER TABLE {} ALTER COLUMN {} DROP DEFAULT;",
3392 qn(&td.module, &td.table),
3393 qi(col)
3394 ),
3395 );
3396 }
3397 let placeholder = format!("cast_expr__{col}");
3405 let default_expr = if enum_target(schema, target_type) {
3410 let text_type = if target_type.ends_with("[]") { "text[]" } else { "text" };
3411 format!("{}::{text_type}::{target_type}", qi(col))
3412 } else {
3413 format!("{}::{target_type}", qi(col))
3414 };
3415 required_input.push(RequiredInput {
3416 placeholder: placeholder.clone(),
3417 prompt: format!(
3418 "Please specify a conversion expression to alter the type of property '{col}' of {}",
3419 verbosename_type(&td.module, &td.name),
3420 ),
3421 default_expr,
3422 type_name: format!("{}::{}", td.module, td.name),
3423 });
3424 push_tx(
3425 ops,
3426 format!(
3427 "ALTER TABLE {} ALTER COLUMN {} TYPE {} USING \\({});",
3428 qn(&td.module, &td.table),
3429 qi(col),
3430 target_type,
3431 placeholder
3432 ),
3433 );
3434 }
3435 for (_, _, ddl) in &affected_views {
3436 push_tx(ops, ddl.clone());
3437 }
3438 }
3439
3440 for p in &td.properties {
3442 let Some(cur) = existing_col_map.get(p.name.as_str()) else {
3443 continue;
3444 };
3445 if cur.is_generated {
3446 continue;
3447 }
3448 if !cur.nullable && p.nullable {
3449 push_tx(
3451 ops,
3452 format!(
3453 "ALTER TABLE {} ALTER COLUMN {} DROP NOT NULL;",
3454 qn(&td.module, &td.table),
3455 qi(&p.name)
3456 ),
3457 );
3458 } else if cur.nullable && !p.nullable && !for_migration {
3459 push_tx(
3461 ops,
3462 format!(
3463 "ALTER TABLE {} ALTER COLUMN {} SET NOT NULL;",
3464 qn(&td.module, &td.table),
3465 qi(&p.name)
3466 ),
3467 );
3468 }
3471
3472 let target_default = resolve_default(p, schema);
3474 let db_default = cur.column_default.as_deref();
3475 match (&target_default, db_default) {
3476 (Some(want), Some(have)) if want != have => {
3477 push_tx(
3478 ops,
3479 format!(
3480 "ALTER TABLE {} ALTER COLUMN {} SET DEFAULT {};",
3481 qn(&td.module, &td.table),
3482 qi(&p.name),
3483 want
3484 ),
3485 );
3486 }
3487 (Some(want), None) => {
3488 push_tx(
3489 ops,
3490 format!(
3491 "ALTER TABLE {} ALTER COLUMN {} SET DEFAULT {};",
3492 qn(&td.module, &td.table),
3493 qi(&p.name),
3494 want
3495 ),
3496 );
3497 }
3498 (None, Some(_)) => {
3499 push_tx(
3500 ops,
3501 format!(
3502 "ALTER TABLE {} ALTER COLUMN {} DROP DEFAULT;",
3503 qn(&td.module, &td.table),
3504 qi(&p.name)
3505 ),
3506 );
3507 }
3508 _ => {}
3509 }
3510 }
3511 for l in &td.links {
3512 if l.is_junction_backed() {
3513 continue;
3514 }
3515 let col = format!("{}_id", l.name);
3516 let Some(cur) = existing_col_map.get(col.as_str()) else {
3517 continue;
3518 };
3519 if !cur.nullable && l.nullable {
3520 push_tx(
3521 ops,
3522 format!(
3523 "ALTER TABLE {} ALTER COLUMN {} DROP NOT NULL;",
3524 qn(&td.module, &td.table),
3525 qi(&col)
3526 ),
3527 );
3528 } else if cur.nullable && !l.nullable && !for_migration {
3529 push_tx(
3530 ops,
3531 format!(
3532 "ALTER TABLE {} ALTER COLUMN {} SET NOT NULL;",
3533 qn(&td.module, &td.table),
3534 qi(&col)
3535 ),
3536 );
3537 }
3538
3539 let target_default = resolve_link_default(l, schema);
3540 let db_default = cur.column_default.as_deref();
3541 match (&target_default, db_default) {
3542 (Some(want), Some(have)) if want != have => {
3543 push_tx(
3544 ops,
3545 format!(
3546 "ALTER TABLE {} ALTER COLUMN {} SET DEFAULT {};",
3547 qn(&td.module, &td.table),
3548 qi(&col),
3549 want
3550 ),
3551 );
3552 }
3553 (Some(want), None) => {
3554 push_tx(
3555 ops,
3556 format!(
3557 "ALTER TABLE {} ALTER COLUMN {} SET DEFAULT {};",
3558 qn(&td.module, &td.table),
3559 qi(&col),
3560 want
3561 ),
3562 );
3563 }
3564 (None, Some(_)) => {
3565 push_tx(
3566 ops,
3567 format!(
3568 "ALTER TABLE {} ALTER COLUMN {} DROP DEFAULT;",
3569 qn(&td.module, &td.table),
3570 qi(&col)
3571 ),
3572 );
3573 }
3574 _ => {}
3575 }
3576 }
3577
3578 let target_cols: HashSet<String> = td
3580 .properties
3581 .iter()
3582 .map(|p| p.name.clone())
3583 .chain(
3584 td.links
3585 .iter()
3586 .filter(|l| !l.is_junction_backed())
3587 .map(|l| format!("{}_id", l.name)),
3588 )
3589 .collect();
3590 for col in &existing.columns {
3591 let n = col.name.as_str();
3592 if target_cols.contains(n) {
3593 continue;
3594 }
3595 if n.starts_with("__") && n.ends_with("__") {
3596 continue;
3597 }
3598 push_tx(
3599 ops,
3600 format!(
3601 "ALTER TABLE {} DROP COLUMN IF EXISTS {};",
3602 qn(&td.module, &td.table),
3603 qi(n)
3604 ),
3605 );
3606 }
3607}
3608
3609fn emit_fk_diff(
3612 td: &TypeDescriptor,
3613 existing: Option<&DbTable>,
3614 type_map: &HashMap<String, (&str, &str)>,
3615 polymorphic: &HashSet<String>,
3616 ops: &mut Vec<DiffOp>,
3617) {
3618 use crate::schema::{DeleteAction, DeleteSide};
3619
3620 let existing_fk_names: HashSet<&str> = existing
3621 .map(|e| e.foreign_keys.iter().map(|fk| fk.constraint_name.as_str()).collect())
3622 .unwrap_or_default();
3623
3624 for l in &td.links {
3625 if l.is_junction_backed() {
3626 continue;
3627 }
3628 if polymorphic.contains(&l.target) {
3631 continue;
3632 }
3633 let cname = format!("{}_{}_fkey", td.table, l.name);
3634 if existing_fk_names.contains(cname.as_str()) {
3635 continue;
3636 }
3637 let Some((tgt_module, tgt_table)) = type_map.get(&l.target) else {
3638 continue;
3639 };
3640 let needs_deferred = crate::export::needs_deferred_target_fk(&l.on_delete);
3647 let on_delete = l
3648 .on_delete
3649 .iter()
3650 .find(|p| p.side == DeleteSide::Target)
3651 .map(|p| match &p.action {
3652 DeleteAction::Restrict if needs_deferred => " DEFERRABLE INITIALLY DEFERRED",
3653 DeleteAction::Restrict => " ON DELETE RESTRICT",
3654 DeleteAction::DeferredRestrict => " DEFERRABLE INITIALLY DEFERRED",
3655 DeleteAction::DeleteSource => " ON DELETE CASCADE",
3656 DeleteAction::Allow => " ON DELETE SET NULL",
3657 _ => " ON DELETE RESTRICT",
3658 })
3659 .unwrap_or(if needs_deferred {
3660 " DEFERRABLE INITIALLY DEFERRED"
3661 } else {
3662 " ON DELETE RESTRICT"
3663 });
3664 push_tx(
3665 ops,
3666 format!(
3667 "ALTER TABLE {} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {}(id){};",
3668 qn(&td.module, &td.table),
3669 qi(&cname),
3670 qi(&format!("{}_id", l.name)),
3671 qn(tgt_module, tgt_table),
3672 on_delete
3673 ),
3674 );
3675 }
3676}
3677
3678#[allow(clippy::too_many_arguments)]
3686fn emit_junction_table(
3687 td: &TypeDescriptor,
3688 ml_name: &str,
3689 through: Option<&str>,
3690 schema: &SchemaDescriptor,
3691 single: bool,
3692 ops: &mut Vec<DiffOp>,
3693) {
3694 let jt_name = format!("{}.{}", td.table, ml_name);
3695 let src_on_delete = " ON DELETE CASCADE";
3696 let mut col_lines = format!(
3699 " source uuid NOT NULL REFERENCES {}(id){},\n target uuid NOT NULL",
3700 qn(&td.module, &td.table),
3701 src_on_delete,
3702 );
3703
3704 if let Some(through_qname) = through
3706 && let Some(through_td) = schema
3707 .types
3708 .iter()
3709 .find(|t| format!("{}::{}", t.module, t.name) == through_qname && t.junction)
3710 {
3711 for p in &through_td.properties {
3712 if p.name == "id" {
3713 continue;
3714 }
3715 let not_null = if p.nullable { "" } else { " NOT NULL" };
3716 col_lines.push_str(&format!(",\n {} {}{}", qi(&p.name), col_type_str(p), not_null));
3717 }
3718 }
3719
3720 let pk_clause = if single {
3721 "PRIMARY KEY (source)"
3722 } else {
3723 "PRIMARY KEY (source, target)"
3724 };
3725 push_tx(
3728 ops,
3729 format!(
3730 "CREATE TABLE IF NOT EXISTS {} (\n{},\n {}\n);",
3731 qn(&td.module, &jt_name),
3732 col_lines,
3733 pk_clause,
3734 ),
3735 );
3736}
3737
3738fn diff_states_inner(before: &DbState, after: &DbState) -> Vec<DiffOp> {
3741 let mut ops: Vec<DiffOp> = Vec::new();
3742
3743 let before_schemas: HashSet<&str> = before.schemas.iter().map(|s| s.as_str()).collect();
3744 let before_tables: HashMap<(&str, &str), &DbTable> = before
3745 .tables
3746 .iter()
3747 .map(|t| ((t.schema.as_str(), t.name.as_str()), t))
3748 .collect();
3749 let before_enums: HashMap<(&str, &str), &DbEnum> = before
3750 .enums
3751 .iter()
3752 .map(|e| ((e.schema.as_str(), e.name.as_str()), e))
3753 .collect();
3754 let before_domains: HashSet<(&str, &str)> = before
3755 .domains
3756 .iter()
3757 .map(|d| (d.schema.as_str(), d.name.as_str()))
3758 .collect();
3759
3760 for schema in &after.schemas {
3762 if schema == "default" {
3763 continue;
3764 } if !before_schemas.contains(schema.as_str()) {
3766 push_tx(&mut ops, format!("CREATE SCHEMA IF NOT EXISTS {};", pg_schema(schema)));
3767 }
3768 }
3769
3770 for e in &after.enums {
3772 match before_enums.get(&(e.schema.as_str(), e.name.as_str())) {
3773 None => {
3774 let members: Vec<String> = e
3775 .members
3776 .iter()
3777 .map(|m| format!("'{}'", m.replace('\'', "''")))
3778 .collect();
3779 push_tx(
3780 &mut ops,
3781 format!(
3782 "DO $$ BEGIN CREATE TYPE {}.{} AS ENUM ({}); \
3783 EXCEPTION WHEN duplicate_object THEN NULL; END $$;",
3784 pg_schema(&e.schema),
3785 qi(&e.name),
3786 members.join(", ")
3787 ),
3788 );
3789 }
3790 Some(existing) => {
3791 let existing_set: HashSet<&str> = existing.members.iter().map(|m| m.as_str()).collect();
3792 for member in &e.members {
3793 if !existing_set.contains(member.as_str()) {
3794 push_tx(
3795 &mut ops,
3796 format!(
3797 "ALTER TYPE {}.{} ADD VALUE IF NOT EXISTS '{}';",
3798 pg_schema(&e.schema),
3799 qi(&e.name),
3800 member.replace('\'', "''")
3801 ),
3802 );
3803 }
3804 }
3805 }
3806 }
3807 }
3808
3809 for d in &after.domains {
3811 if !before_domains.contains(&(d.schema.as_str(), d.name.as_str())) {
3812 push_tx(
3815 &mut ops,
3816 format!(
3817 "-- TODO: recreate domain {}.{} (reconstruct DDL from source migrations)",
3818 pg_schema(&d.schema),
3819 qi(&d.name)
3820 ),
3821 );
3822 }
3823 }
3824
3825 let mut new_tables: HashSet<(String, String)> = HashSet::new();
3827 for t in &after.tables {
3828 let key = (t.schema.as_str(), t.name.as_str());
3829 match before_tables.get(&key) {
3830 None => {
3831 emit_create_table_from_db(t, &mut ops);
3833 new_tables.insert((t.schema.clone(), t.name.clone()));
3834 }
3835 Some(before_t) => {
3836 emit_column_diff_from_db(t, before_t, &mut ops);
3838 }
3839 }
3840 }
3841
3842 for t in &after.tables {
3844 if let Some(before_t) = before_tables.get(&(t.schema.as_str(), t.name.as_str())) {
3845 let before_fk_names: HashSet<&str> = before_t
3846 .foreign_keys
3847 .iter()
3848 .map(|fk| fk.constraint_name.as_str())
3849 .collect();
3850 for fk in &t.foreign_keys {
3851 if !before_fk_names.contains(fk.constraint_name.as_str()) {
3852 push_tx(
3855 &mut ops,
3856 format!(
3857 "ALTER TABLE {}.{} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {}.{}(id);",
3858 pg_schema(&t.schema),
3859 qi(&t.name),
3860 qi(&fk.constraint_name),
3861 qi(&fk.local_column),
3862 pg_schema(&fk.ref_schema),
3863 qi(&fk.ref_table)
3864 ),
3865 );
3866 }
3867 }
3868 }
3869 }
3870
3871 for t in &after.tables {
3873 let table_is_new = new_tables.contains(&(t.schema.clone(), t.name.clone()));
3874 let before_indexes = before_tables
3875 .get(&(t.schema.as_str(), t.name.as_str()))
3876 .map(|bt| bt.indexes.as_slice())
3877 .unwrap_or_default();
3878 for idx in &t.indexes {
3879 let already_there = if idx.columns.is_empty() {
3885 before_indexes.iter().any(|b| b.name == idx.name)
3886 } else {
3887 before_indexes
3888 .iter()
3889 .any(|b| b.columns == idx.columns && b.is_unique == idx.is_unique)
3890 };
3891 if already_there {
3892 continue;
3893 }
3894 if idx.columns.is_empty() {
3897 continue;
3898 }
3899 let use_concurrently = !table_is_new;
3900 let concurrently = if use_concurrently { "CONCURRENTLY " } else { "" };
3901 let unique = if idx.is_unique { "UNIQUE " } else { "" };
3902 let idx_sql = format!(
3903 "CREATE {unique}INDEX {concurrently}IF NOT EXISTS {} ON {}.{} ({});",
3904 qi(&idx.name),
3905 pg_schema(&t.schema),
3906 qi(&t.name),
3907 idx.columns.iter().map(|c| qi(c)).collect::<Vec<_>>().join(", "),
3908 );
3909 ops.push(DiffOp {
3910 sql: idx_sql,
3911 non_transactional: use_concurrently,
3912 });
3913 }
3914 }
3915
3916 let after_tables: HashSet<(&str, &str)> = after
3918 .tables
3919 .iter()
3920 .map(|t| (t.schema.as_str(), t.name.as_str()))
3921 .collect();
3922 for t in &before.tables {
3923 if !after_tables.contains(&(t.schema.as_str(), t.name.as_str())) {
3924 push_tx(
3925 &mut ops,
3926 format!("DROP TABLE IF EXISTS {}.{} CASCADE;", pg_schema(&t.schema), qi(&t.name)),
3927 );
3928 }
3929 }
3930
3931 let after_enum_set: HashSet<(&str, &str)> = after
3933 .enums
3934 .iter()
3935 .map(|e| (e.schema.as_str(), e.name.as_str()))
3936 .collect();
3937 for e in &before.enums {
3938 if !after_enum_set.contains(&(e.schema.as_str(), e.name.as_str())) {
3939 push_tx(
3940 &mut ops,
3941 format!("DROP TYPE IF EXISTS {}.{} CASCADE;", pg_schema(&e.schema), qi(&e.name)),
3942 );
3943 }
3944 }
3945
3946 let after_schema_set: HashSet<&str> = after.schemas.iter().map(|s| s.as_str()).collect();
3948 for schema in &before.schemas {
3949 if schema == "default" {
3950 continue;
3951 } if !after_schema_set.contains(schema.as_str()) {
3953 push_tx(
3954 &mut ops,
3955 format!("DROP SCHEMA IF EXISTS {} CASCADE;", pg_schema(schema)),
3956 );
3957 }
3958 }
3959
3960 ops
3961}
3962
3963fn emit_create_table_from_db(t: &DbTable, ops: &mut Vec<DiffOp>) {
3964 let mut lines: Vec<String> = Vec::new();
3965 for col in &t.columns {
3966 let not_null = if col.nullable { "" } else { " NOT NULL" };
3967 if col.is_generated {
3968 lines.push(format!(
3970 " {} {} GENERATED ALWAYS AS (/* see source */) STORED",
3971 qi(&col.name),
3972 col.pg_type
3973 ));
3974 } else {
3975 lines.push(format!(" {} {}{}", qi(&col.name), col.pg_type, not_null));
3976 }
3977 }
3978 push_tx(
3979 ops,
3980 format!(
3981 "CREATE TABLE IF NOT EXISTS {}.{} (\n{}\n);",
3982 pg_schema(&t.schema),
3983 qi(&t.name),
3984 lines.join(",\n")
3985 ),
3986 );
3987}
3988
3989fn emit_column_diff_from_db(after: &DbTable, before: &DbTable, ops: &mut Vec<DiffOp>) {
3990 let before_cols: HashSet<&str> = before.columns.iter().map(|c| c.name.as_str()).collect();
3991 let after_cols: HashSet<&str> = after.columns.iter().map(|c| c.name.as_str()).collect();
3992
3993 for col in &after.columns {
3994 if !before_cols.contains(col.name.as_str()) {
3995 let not_null = if col.nullable { "" } else { " NOT NULL" };
3996 push_tx(
3997 ops,
3998 format!(
3999 "ALTER TABLE {}.{} ADD COLUMN IF NOT EXISTS {} {}{};",
4000 pg_schema(&after.schema),
4001 qi(&after.name),
4002 qi(&col.name),
4003 col.pg_type,
4004 not_null
4005 ),
4006 );
4007 }
4008 }
4009 for col in &before.columns {
4010 if !after_cols.contains(col.name.as_str()) {
4011 push_tx(
4012 ops,
4013 format!(
4014 "ALTER TABLE {}.{} DROP COLUMN IF EXISTS {};",
4015 pg_schema(&after.schema),
4016 qi(&after.name),
4017 qi(&col.name)
4018 ),
4019 );
4020 }
4021 }
4022}
4023
4024#[cfg(test)]
4027mod tests {
4028 use super::*;
4029 use crate::schema::{EnumDescriptor, LinkDescriptor, PropertyDescriptor, SchemaDescriptor, TypeDescriptor};
4030
4031 fn empty_state() -> DbState {
4032 DbState::default()
4033 }
4034
4035 fn prop(name: &str, pg_type: &str, nullable: bool) -> PropertyDescriptor {
4036 PropertyDescriptor {
4037 name: name.into(),
4038 pg_type: pg_type.into(),
4039 nullable,
4040 default_sql: if name == "id" { Some("uuidv7()".into()) } else { None },
4041 default_pyql: None,
4042 description: None,
4043 check_constraints: vec![],
4044 is_exclusive: name == "id",
4045 is_pk: name == "id",
4046 is_readonly: name == "id",
4047 rewrites: vec![],
4048 tuple_members: None,
4049 column_type: None,
4050 }
4051 }
4052
4053 fn simple_type(module: &str, name: &str, table: &str) -> TypeDescriptor {
4054 TypeDescriptor {
4055 name: name.into(),
4056 module: module.into(),
4057 table: table.into(),
4058 abstract_: false,
4059 materialized: false,
4060 description: None,
4061 parents: vec![],
4062 interfaces: vec![],
4063 bases: vec![],
4064 properties: vec![prop("id", "uuid", false), prop("name", "text", true)],
4065 links: vec![],
4066 multilinks: vec![],
4067 computed: vec![],
4068 constraints: vec![],
4069 indexes: vec![],
4070 partition: None,
4071 vector_indexes: vec![],
4072 search_indexes: vec![],
4073 triggers: vec![],
4074 junction: false,
4075 signals: vec![],
4076 }
4077 }
4078
4079 #[test]
4082 fn test_schema_content_changed_detects_a_readonly_only_flip() {
4083 let before = simple_type("default", "Person", "Person");
4087 let mut after = before.clone();
4088 after.properties[1].is_readonly = true; assert_ne!(before.properties[1].is_readonly, after.properties[1].is_readonly);
4090
4091 let schema_before = SchemaDescriptor {
4092 types: vec![before],
4093 scalars: vec![],
4094 enums: vec![],
4095 named_tuples: vec![],
4096 globals: vec![],
4097 functions: vec![],
4098 aliases: vec![],
4099 channels: vec![],
4100 ..Default::default()
4101 };
4102 let schema_after = SchemaDescriptor {
4103 types: vec![after],
4104 scalars: vec![],
4105 enums: vec![],
4106 named_tuples: vec![],
4107 globals: vec![],
4108 functions: vec![],
4109 aliases: vec![],
4110 channels: vec![],
4111 ..Default::default()
4112 };
4113 assert!(schema_content_changed(&schema_after, Some(&schema_before)));
4114 }
4115
4116 #[test]
4117 fn test_schema_content_changed_detects_a_new_rewrite() {
4118 let before = simple_type("default", "Person", "Person");
4119 let mut after = before.clone();
4120 after.properties[1].rewrites.push(crate::schema::RewriteEntry {
4121 on: 1,
4122 handler: "str_upper(.name)".into(),
4123 });
4124
4125 let schema_before = SchemaDescriptor {
4126 types: vec![before],
4127 scalars: vec![],
4128 enums: vec![],
4129 named_tuples: vec![],
4130 globals: vec![],
4131 functions: vec![],
4132 aliases: vec![],
4133 channels: vec![],
4134 ..Default::default()
4135 };
4136 let schema_after = SchemaDescriptor {
4137 types: vec![after],
4138 scalars: vec![],
4139 enums: vec![],
4140 named_tuples: vec![],
4141 globals: vec![],
4142 functions: vec![],
4143 aliases: vec![],
4144 channels: vec![],
4145 ..Default::default()
4146 };
4147 assert!(schema_content_changed(&schema_after, Some(&schema_before)));
4148 }
4149
4150 #[test]
4151 fn test_schema_content_changed_is_false_for_identical_schemas() {
4152 let t = simple_type("default", "Person", "Person");
4153 let schema = SchemaDescriptor {
4154 types: vec![t],
4155 scalars: vec![],
4156 enums: vec![],
4157 named_tuples: vec![],
4158 globals: vec![],
4159 functions: vec![],
4160 aliases: vec![],
4161 channels: vec![],
4162 ..Default::default()
4163 };
4164 let other = schema.clone();
4165 assert!(!schema_content_changed(&schema, Some(&other)));
4166 }
4167
4168 #[test]
4169 fn test_schema_content_changed_true_against_none_when_target_is_non_empty() {
4170 let t = simple_type("default", "Person", "Person");
4171 let schema = SchemaDescriptor {
4172 types: vec![t],
4173 scalars: vec![],
4174 enums: vec![],
4175 named_tuples: vec![],
4176 globals: vec![],
4177 functions: vec![],
4178 aliases: vec![],
4179 channels: vec![],
4180 ..Default::default()
4181 };
4182 assert!(
4183 schema_content_changed(&schema, None),
4184 "no prior snapshot at all must count as changed"
4185 );
4186 }
4187
4188 #[test]
4189 fn test_schema_content_changed_false_against_none_when_target_is_also_empty() {
4190 let schema = SchemaDescriptor::default();
4191 assert!(!schema_content_changed(&schema, None));
4192 }
4193
4194 #[test]
4195 fn test_schema_content_changed_still_true_when_ddl_visible_things_also_changed() {
4196 let schema_before = SchemaDescriptor::default();
4200 let schema_after = SchemaDescriptor {
4201 types: vec![simple_type("default", "Person", "Person")],
4202 scalars: vec![],
4203 enums: vec![],
4204 named_tuples: vec![],
4205 globals: vec![],
4206 functions: vec![],
4207 aliases: vec![],
4208 channels: vec![],
4209 ..Default::default()
4210 };
4211 assert!(schema_content_changed(&schema_after, Some(&schema_before)));
4212 }
4213
4214 #[test]
4215 fn test_schema_content_changed_detects_a_new_channel() {
4216 let schema_before = SchemaDescriptor::default();
4221 let schema_after = SchemaDescriptor {
4222 channels: vec![crate::schema::ChannelDescriptor {
4223 name: "UserUpdates".into(),
4224 module: "default".into(),
4225 wire_name: "default__user_updates".into(),
4226 payload: crate::schema::ChannelPayload::Scalar("text".into()),
4227 description: None,
4228 }],
4229 ..Default::default()
4230 };
4231 assert!(schema_content_changed(&schema_after, Some(&schema_before)));
4232 }
4233
4234 #[test]
4235 fn test_new_schema_and_table() {
4236 let schema = SchemaDescriptor {
4237 types: vec![simple_type("catalog", "Product", "Product")],
4238 scalars: vec![],
4239 enums: vec![],
4240 named_tuples: vec![],
4241 globals: vec![],
4242 functions: vec![],
4243 aliases: vec![],
4244 channels: vec![],
4245 ..Default::default()
4246 };
4247 let ops = diff_schema(&schema, &empty_state()).unwrap();
4248 let joined = ops.join("\n");
4249 assert!(
4250 joined.contains("CREATE SCHEMA IF NOT EXISTS \"catalog\""),
4251 "got:\n{joined}"
4252 );
4253 assert!(
4254 joined.contains("CREATE TABLE IF NOT EXISTS \"catalog\".\"Product\""),
4255 "got:\n{joined}"
4256 );
4257 assert!(
4258 joined.contains("CREATE OR REPLACE TRIGGER pylon_cache_invalidate\n AFTER INSERT OR UPDATE OR DELETE ON \"catalog\".\"Product\""),
4259 "new table must get the cache-invalidation trigger; got:\n{joined}"
4260 );
4261 }
4262
4263 fn widget_with_trigger(on: u8, timing: &str, handler: &str) -> TypeDescriptor {
4264 let mut t = simple_type("default", "Widget", "Widget");
4265 t.triggers = vec![crate::schema::TriggerDescriptor {
4266 on,
4267 timing: timing.into(),
4268 handler: handler.into(),
4269 }];
4270 t
4271 }
4272
4273 #[test]
4274 fn test_new_table_with_user_trigger_emits_the_compiled_trigger_ddl() {
4275 let schema = SchemaDescriptor {
4282 types: vec![widget_with_trigger(
4283 1,
4284 "After",
4285 "update Widget set { name := __new__.name }",
4286 )],
4287 scalars: vec![],
4288 enums: vec![],
4289 named_tuples: vec![],
4290 globals: vec![],
4291 functions: vec![],
4292 aliases: vec![],
4293 channels: vec![],
4294 ..Default::default()
4295 };
4296 let ops = diff_schema(&schema, &empty_state()).unwrap();
4297 let joined = ops.join("\n");
4298 assert!(joined.contains("NEW.\"name\""), "got:\n{joined}");
4299 }
4300
4301 #[test]
4302 fn a_trigger_emitted_by_an_older_build_is_replaced() {
4303 let schema = SchemaDescriptor {
4314 types: vec![widget_with_trigger(
4315 1,
4316 "After",
4317 "update Widget set { name := __new__.name }",
4318 )],
4319 scalars: vec![],
4320 enums: vec![],
4321 named_tuples: vec![],
4322 globals: vec![],
4323 functions: vec![],
4324 aliases: vec![],
4325 channels: vec![],
4326 ..Default::default()
4327 };
4328 let mut stale = schema_to_db_state(&schema);
4329 let current_name = stale
4330 .tables
4331 .iter()
4332 .flat_map(|t| t.triggers.iter().cloned())
4333 .find(|n| n.starts_with("Widget_"))
4334 .expect("the fixture should project a Widget trigger");
4335 let stale_name = "Widget_trg_0badc0de".to_string();
4336 for table in &mut stale.tables {
4337 for trigger in &mut table.triggers {
4338 if *trigger == current_name {
4339 *trigger = stale_name.clone();
4340 }
4341 }
4342 }
4343
4344 let joined = diff_schema(&schema, &stale).unwrap().join("\n");
4345 assert!(
4346 joined.contains(&format!("DROP TRIGGER IF EXISTS \"{stale_name}\"")),
4347 "the stale trigger should be dropped, got:\n{joined}"
4348 );
4349 assert!(
4350 joined.contains(¤t_name),
4351 "the current trigger should be created, got:\n{joined}"
4352 );
4353 assert!(
4356 joined.contains(&format!("DROP FUNCTION IF EXISTS \"public\".\"{stale_name}\"()")),
4357 "the orphaned function should be dropped, got:\n{joined}"
4358 );
4359 }
4360
4361 #[test]
4362 fn test_user_trigger_already_present_in_offline_baseline_produces_no_further_steps() {
4363 let schema = SchemaDescriptor {
4368 types: vec![widget_with_trigger(
4369 1,
4370 "After",
4371 "update Widget set { name := __new__.name }",
4372 )],
4373 scalars: vec![],
4374 enums: vec![],
4375 named_tuples: vec![],
4376 globals: vec![],
4377 functions: vec![],
4378 aliases: vec![],
4379 channels: vec![],
4380 ..Default::default()
4381 };
4382 let baseline = schema_to_db_state(&schema);
4383 let steps = diff_schema_steps(&schema, &baseline, &HashMap::new()).unwrap();
4384 assert!(
4385 steps.is_empty(),
4386 "expected zero further migration steps, got: {steps:?}"
4387 );
4388 }
4389
4390 #[test]
4391 fn a_link_target_gaining_a_subtype_loses_its_foreign_key() {
4392 let mut order = simple_type("default", "Order", "Order");
4393 order.links.push(LinkDescriptor {
4394 name: "customer".into(),
4395 target: "default::Person".into(),
4396 nullable: false,
4397 through: None,
4398 description: None,
4399 default_pyql: None,
4400 is_exclusive: false,
4401 is_readonly: false,
4402 rewrites: vec![],
4403 on_delete: vec![],
4404 });
4405 let before = SchemaDescriptor {
4406 types: vec![order, simple_type("default", "Person", "Person")],
4407 scalars: vec![],
4408 enums: vec![],
4409 named_tuples: vec![],
4410 globals: vec![],
4411 functions: vec![],
4412 aliases: vec![],
4413 channels: vec![],
4414 ..Default::default()
4415 };
4416 let mut after = before.clone();
4417 let mut vip = simple_type("default", "Vip", "Vip");
4418 vip.bases = vec!["default::Person".into()];
4419 after.types.push(vip);
4420
4421 let joined = diff_schema(&after, &schema_to_db_state(&before)).unwrap().join("\n");
4422 assert!(
4423 joined.contains("ALTER TABLE \"public\".\"Order\" DROP CONSTRAINT IF EXISTS \"Order_customer_fkey\""),
4424 "got:\n{joined}"
4425 );
4426 let settled = diff_schema_steps(&after, &schema_to_db_state(&after), &HashMap::new()).unwrap();
4427 assert!(settled.is_empty(), "nothing further once applied, got: {settled:?}");
4428 }
4429
4430 #[test]
4431 fn test_new_table_with_plain_link_gets_its_fk_constraint() {
4432 let mut order = simple_type("default", "Order", "Order");
4438 order.links.push(LinkDescriptor {
4439 name: "customer".into(),
4440 target: "default::Person".into(),
4441 nullable: false,
4442 through: None,
4443 description: None,
4444 default_pyql: None,
4445 is_exclusive: false,
4446 is_readonly: false,
4447 rewrites: vec![],
4448 on_delete: vec![],
4449 });
4450 let schema = SchemaDescriptor {
4451 types: vec![order, simple_type("default", "Person", "Person")],
4452 scalars: vec![],
4453 enums: vec![],
4454 named_tuples: vec![],
4455 globals: vec![],
4456 functions: vec![],
4457 aliases: vec![],
4458 channels: vec![],
4459 ..Default::default()
4460 };
4461 let ops = diff_schema(&schema, &empty_state()).unwrap();
4462 let joined = ops.join("\n");
4463 assert!(
4464 joined.contains("ADD CONSTRAINT \"Order_customer_fkey\" FOREIGN KEY (\"customer_id\") REFERENCES \"public\".\"Person\"(id)"),
4465 "new table's plain link must get its FK constraint in the same diff; got:\n{joined}"
4466 );
4467 }
4468
4469 #[test]
4470 fn test_an_existing_exclusive_multilink_junction_gains_its_unique_target() {
4471 use crate::schema::MultiLinkDescriptor;
4472
4473 let mut person = simple_type("default", "Person", "Person");
4474 person.multilinks.push(MultiLinkDescriptor {
4475 name: "keys".into(),
4476 target: "default::Key".into(),
4477 through: None,
4478 nullable: false,
4479 description: None,
4480 default_pyql: None,
4481 on_delete: vec![],
4482 is_exclusive: true,
4483 });
4484 let schema = SchemaDescriptor {
4485 types: vec![person, simple_type("default", "Key", "Key")],
4486 scalars: vec![],
4487 enums: vec![],
4488 named_tuples: vec![],
4489 globals: vec![],
4490 functions: vec![],
4491 aliases: vec![],
4492 channels: vec![],
4493 ..Default::default()
4494 };
4495 let mut state = schema_to_db_state(&schema);
4496 assert!(diff_schema(&schema, &state).unwrap().is_empty());
4497
4498 for table in state.tables.iter_mut().filter(|t| t.name == "Person.keys") {
4499 table.indexes.clear();
4500 }
4501 let joined = diff_schema(&schema, &state).unwrap().join("\n");
4502 assert!(
4503 joined.contains("CREATE UNIQUE INDEX IF NOT EXISTS \"Person.keys_target_key\" ON \"public\".\"Person.keys\" (\"target\")"),
4504 "got:\n{joined}"
4505 );
4506 }
4507
4508 #[test]
4509 fn test_cache_invalidate_trigger_not_duplicated_for_junction_through_type() {
4510 use crate::schema::MultiLinkDescriptor;
4511
4512 let mut product = simple_type("default", "Product", "Product");
4513 product.multilinks.push(MultiLinkDescriptor {
4514 name: "tags".into(),
4515 target: "default::Tag".into(),
4516 through: Some("default::ProductTag".into()),
4517 nullable: false,
4518 description: None,
4519 default_pyql: None,
4520 on_delete: vec![],
4521 is_exclusive: false,
4522 });
4523 let mut junction = simple_type("default", "ProductTag", "Product.tags");
4524 junction.junction = true;
4525
4526 let schema = SchemaDescriptor {
4527 types: vec![product, junction, simple_type("default", "Tag", "Tag")],
4528 scalars: vec![],
4529 enums: vec![],
4530 named_tuples: vec![],
4531 globals: vec![],
4532 functions: vec![],
4533 aliases: vec![],
4534 channels: vec![],
4535 ..Default::default()
4536 };
4537 let ops = diff_schema(&schema, &empty_state()).unwrap();
4538 let trigger_count = ops
4539 .iter()
4540 .filter(|op| op.contains("AFTER INSERT OR UPDATE OR DELETE ON \"public\".\"Product.tags\""))
4541 .count();
4542 assert_eq!(
4543 trigger_count, 1,
4544 "junction table's own td.table and the owning type's multilink both resolve to \
4545 the same physical table — must be deduped to one trigger, got {trigger_count} in: {ops:?}"
4546 );
4547 }
4548
4549 fn person_with_junction_backed_spouse() -> SchemaDescriptor {
4550 let mut person = simple_type("default", "Person", "Person");
4551 person.links.push(LinkDescriptor {
4552 name: "spouse".into(),
4553 target: "default::Person".into(),
4554 nullable: true,
4555 through: Some("default::Marriage".into()),
4556 description: None,
4557 default_pyql: None,
4558 is_exclusive: true,
4559 is_readonly: false,
4560 rewrites: vec![],
4561 on_delete: vec![],
4562 });
4563 let mut junction = simple_type("default", "Marriage", "Person.spouse");
4564 junction.junction = true;
4565
4566 SchemaDescriptor {
4567 types: vec![person, junction],
4568 scalars: vec![],
4569 enums: vec![],
4570 named_tuples: vec![],
4571 globals: vec![],
4572 functions: vec![],
4573 aliases: vec![],
4574 channels: vec![],
4575 ..Default::default()
4576 }
4577 }
4578
4579 #[test]
4580 fn test_junction_backed_single_link_creates_junction_table_from_scratch() {
4581 let schema = person_with_junction_backed_spouse();
4582 let ops = diff_schema(&schema, &empty_state()).unwrap();
4583 let joined = ops.join("\n");
4584 assert!(
4585 !joined.contains("spouse_id"),
4586 "no {{name}}_id column/FK for a junction-backed link, got:\n{joined}"
4587 );
4588 assert!(
4589 joined.contains("CREATE TABLE IF NOT EXISTS \"public\".\"Person.spouse\""),
4590 "got:\n{joined}"
4591 );
4592 assert!(joined.contains("PRIMARY KEY (source)"), "got:\n{joined}");
4593 assert!(
4594 joined.contains("CREATE UNIQUE INDEX IF NOT EXISTS \"Person.spouse_target_key\" ON \"public\".\"Person.spouse\" (\"target\")"),
4595 "got:\n{joined}"
4596 );
4597 }
4598
4599 #[test]
4600 fn test_junction_backed_single_link_diff_is_idempotent_once_applied() {
4601 let schema = person_with_junction_backed_spouse();
4609 let state = DbState {
4610 schemas: vec![],
4611 tables: vec![
4612 DbTable {
4613 schema: "default".into(),
4614 name: "Person".into(),
4615 columns: vec![
4616 DbColumn {
4617 name: "id".into(),
4618 pg_type: "uuid".into(),
4619 nullable: false,
4620 is_generated: false,
4621 column_default: Some("uuidv7()".into()),
4622 },
4623 DbColumn {
4624 name: "name".into(),
4625 pg_type: "text".into(),
4626 nullable: true,
4627 is_generated: false,
4628 column_default: None,
4629 },
4630 ],
4631 foreign_keys: vec![],
4632 indexes: vec![],
4633 checks: vec![],
4634 triggers: vec!["pylon_cache_invalidate".into()],
4635 },
4636 DbTable {
4637 schema: "default".into(),
4638 name: "Person.spouse".into(),
4639 columns: vec![
4640 DbColumn {
4641 name: "source".into(),
4642 pg_type: "uuid".into(),
4643 nullable: false,
4644 is_generated: false,
4645 column_default: None,
4646 },
4647 DbColumn {
4648 name: "target".into(),
4649 pg_type: "uuid".into(),
4650 nullable: false,
4651 is_generated: false,
4652 column_default: None,
4653 },
4654 DbColumn {
4655 name: "name".into(),
4656 pg_type: "text".into(),
4657 nullable: true,
4658 is_generated: false,
4659 column_default: None,
4660 },
4661 ],
4662 foreign_keys: vec![
4663 DbForeignKey {
4664 constraint_name: "Person_spouse_source_fkey".into(),
4665 local_column: "source".into(),
4666 ref_schema: "default".into(),
4667 ref_table: "Person".into(),
4668 },
4669 DbForeignKey {
4670 constraint_name: "Person_spouse_target_fkey".into(),
4671 local_column: "target".into(),
4672 ref_schema: "default".into(),
4673 ref_table: "Person".into(),
4674 },
4675 ],
4676 indexes: vec![DbIndex {
4677 name: "Person.spouse_target_key".into(),
4678 is_unique: true,
4679 method: "btree".into(),
4680 columns: vec!["target".into()],
4681 predicate: None,
4682 key: None,
4683 }],
4684 checks: vec![],
4685 triggers: vec!["pylon_cache_invalidate".into()],
4686 },
4687 ],
4688 enums: vec![],
4689 domains: vec![],
4690 ..DbState::default()
4691 };
4692 let ops = diff_schema(&schema, &state).unwrap();
4693 assert!(
4694 ops.is_empty(),
4695 "already-migrated junction-backed single link must diff to no ops, got: {:?}",
4696 ops
4697 );
4698 }
4699
4700 #[test]
4701 fn test_cache_invalidate_trigger_backfilled_on_pre_existing_table() {
4702 let schema = SchemaDescriptor {
4703 types: vec![simple_type("default", "Person", "Person")],
4704 scalars: vec![],
4705 enums: vec![],
4706 named_tuples: vec![],
4707 globals: vec![],
4708 functions: vec![],
4709 aliases: vec![],
4710 channels: vec![],
4711 ..Default::default()
4712 };
4713 let state = DbState {
4714 schemas: vec!["default".into()],
4715 tables: vec![DbTable {
4716 schema: "default".into(),
4717 name: "Person".into(),
4718 columns: vec![
4719 DbColumn {
4720 name: "id".into(),
4721 pg_type: "uuid".into(),
4722 nullable: false,
4723 is_generated: false,
4724 column_default: Some("uuidv7()".into()),
4725 },
4726 DbColumn {
4727 name: "name".into(),
4728 pg_type: "text".into(),
4729 nullable: true,
4730 is_generated: false,
4731 column_default: None,
4732 },
4733 ],
4734 foreign_keys: vec![],
4735 indexes: vec![],
4736 checks: vec![],
4737 triggers: vec![], }],
4739 enums: vec![],
4740 domains: vec![],
4741 ..DbState::default()
4742 };
4743 let ops = diff_schema(&schema, &state).unwrap();
4744 let joined = ops.join("\n");
4745 assert!(
4746 joined.contains("CREATE OR REPLACE TRIGGER pylon_cache_invalidate\n AFTER INSERT OR UPDATE OR DELETE ON \"public\".\"Person\""),
4747 "pre-existing table missing the trigger must get it backfilled; got:\n{joined}"
4748 );
4749 }
4750
4751 #[test]
4752 fn test_cache_invalidate_trigger_not_dropped_when_already_present() {
4753 let schema = SchemaDescriptor {
4754 types: vec![simple_type("default", "Person", "Person")],
4755 scalars: vec![],
4756 enums: vec![],
4757 named_tuples: vec![],
4758 globals: vec![],
4759 functions: vec![],
4760 aliases: vec![],
4761 channels: vec![],
4762 ..Default::default()
4763 };
4764 let state = DbState {
4765 schemas: vec!["default".into()],
4766 tables: vec![DbTable {
4767 schema: "default".into(),
4768 name: "Person".into(),
4769 columns: vec![
4770 DbColumn {
4771 name: "id".into(),
4772 pg_type: "uuid".into(),
4773 nullable: false,
4774 is_generated: false,
4775 column_default: Some("uuidv7()".into()),
4776 },
4777 DbColumn {
4778 name: "name".into(),
4779 pg_type: "text".into(),
4780 nullable: true,
4781 is_generated: false,
4782 column_default: None,
4783 },
4784 ],
4785 foreign_keys: vec![],
4786 indexes: vec![],
4787 checks: vec![],
4788 triggers: vec!["pylon_cache_invalidate".into()],
4789 }],
4790 enums: vec![],
4791 domains: vec![],
4792 ..DbState::default()
4793 };
4794 let ops = diff_schema(&schema, &state).unwrap();
4795 assert!(
4796 ops.iter()
4797 .all(|op| !op.contains("DROP TRIGGER") && !op.contains("pylon_cache_invalidate")),
4798 "already-present trigger must not be re-created or dropped; got: {:?}",
4799 ops
4800 );
4801 }
4802
4803 #[test]
4804 fn test_no_ops_when_in_sync() {
4805 let schema = SchemaDescriptor {
4806 types: vec![simple_type("default", "Person", "Person")],
4807 scalars: vec![],
4808 enums: vec![],
4809 named_tuples: vec![],
4810 globals: vec![],
4811 functions: vec![],
4812 aliases: vec![],
4813 channels: vec![],
4814 ..Default::default()
4815 };
4816 let state = DbState {
4817 schemas: vec!["default".into()],
4818 tables: vec![DbTable {
4819 schema: "default".into(),
4820 name: "Person".into(),
4821 columns: vec![
4822 DbColumn {
4823 name: "id".into(),
4824 pg_type: "uuid".into(),
4825 nullable: false,
4826 is_generated: false,
4827 column_default: Some("uuidv7()".into()),
4828 },
4829 DbColumn {
4830 name: "name".into(),
4831 pg_type: "text".into(),
4832 nullable: true,
4833 is_generated: false,
4834 column_default: None,
4835 },
4836 ],
4837 foreign_keys: vec![],
4838 indexes: vec![],
4839 checks: vec![],
4840 triggers: vec!["pylon_cache_invalidate".into()],
4841 }],
4842 enums: vec![],
4843 domains: vec![],
4844 ..DbState::default()
4845 };
4846 let ops = diff_schema(&schema, &state).unwrap();
4847 assert!(ops.is_empty(), "expected no ops, got: {:?}", ops);
4848 }
4849
4850 #[test]
4851 fn test_add_column() {
4852 let mut td = simple_type("default", "Person", "Person");
4853 td.properties.push(prop("email", "text", true));
4854 let schema = SchemaDescriptor {
4855 types: vec![td],
4856 scalars: vec![],
4857 enums: vec![],
4858 named_tuples: vec![],
4859 globals: vec![],
4860 functions: vec![],
4861 aliases: vec![],
4862 channels: vec![],
4863 ..Default::default()
4864 };
4865 let state = DbState {
4866 schemas: vec!["default".into()],
4867 tables: vec![DbTable {
4868 schema: "default".into(),
4869 name: "Person".into(),
4870 columns: vec![
4871 DbColumn {
4872 name: "id".into(),
4873 pg_type: "uuid".into(),
4874 nullable: false,
4875 is_generated: false,
4876 column_default: Some("uuidv7()".into()),
4877 },
4878 DbColumn {
4879 name: "name".into(),
4880 pg_type: "text".into(),
4881 nullable: true,
4882 is_generated: false,
4883 column_default: None,
4884 },
4885 ],
4886 foreign_keys: vec![],
4887 indexes: vec![],
4888 checks: vec![],
4889 triggers: vec![],
4890 }],
4891 enums: vec![],
4892 domains: vec![],
4893 ..DbState::default()
4894 };
4895 let ops = diff_schema(&schema, &state).unwrap();
4896 let joined = ops.join("\n");
4897 assert!(joined.contains("ADD COLUMN IF NOT EXISTS \"email\""), "got:\n{joined}");
4898 }
4899
4900 #[test]
4901 fn test_property_type_change_emits_alter_column_type() {
4902 let mut td = simple_type("default", "Person", "Person");
4905 td.properties.push(prop("rating", "int8", true));
4906 let schema = SchemaDescriptor {
4907 types: vec![td],
4908 scalars: vec![],
4909 enums: vec![],
4910 named_tuples: vec![],
4911 globals: vec![],
4912 functions: vec![],
4913 aliases: vec![],
4914 channels: vec![],
4915 ..Default::default()
4916 };
4917 let state = DbState {
4918 schemas: vec!["default".into()],
4919 tables: vec![DbTable {
4920 schema: "default".into(),
4921 name: "Person".into(),
4922 columns: vec![
4923 DbColumn {
4924 name: "id".into(),
4925 pg_type: "uuid".into(),
4926 nullable: false,
4927 is_generated: false,
4928 column_default: Some("uuidv7()".into()),
4929 },
4930 DbColumn {
4931 name: "name".into(),
4932 pg_type: "text".into(),
4933 nullable: true,
4934 is_generated: false,
4935 column_default: None,
4936 },
4937 DbColumn {
4938 name: "rating".into(),
4939 pg_type: "text".into(),
4940 nullable: true,
4941 is_generated: false,
4942 column_default: None,
4943 },
4944 ],
4945 foreign_keys: vec![],
4946 indexes: vec![],
4947 checks: vec![],
4948 triggers: vec![],
4949 }],
4950 enums: vec![],
4951 domains: vec![],
4952 ..DbState::default()
4953 };
4954 let ops = diff_schema(&schema, &state).unwrap();
4955 let joined = ops.join("\n");
4956 assert!(
4957 joined.contains(
4958 "ALTER TABLE \"public\".\"Person\" ALTER COLUMN \"rating\" TYPE int8 USING \"rating\"::int8;"
4959 ),
4960 "got:\n{joined}"
4961 );
4962 }
4963
4964 #[test]
4965 fn test_a_column_moving_between_enums_converts_through_text() {
4966 let mut td = simple_type("default", "Person", "Person");
4971 td.properties.push(prop("verb", "\"public\".\"HttpMethod\"", true));
4972 let schema = SchemaDescriptor {
4973 types: vec![td],
4974 enums: vec![crate::schema::EnumDescriptor {
4975 name: "HttpMethod".into(),
4976 module: "default".into(),
4977 members: vec!["GET".into(), "POST".into()],
4978 }],
4979 ..Default::default()
4980 };
4981 let state = DbState {
4982 schemas: vec!["default".into()],
4983 tables: vec![DbTable {
4984 schema: "default".into(),
4985 name: "Person".into(),
4986 columns: vec![
4987 DbColumn {
4988 name: "id".into(),
4989 pg_type: "uuid".into(),
4990 nullable: false,
4991 is_generated: false,
4992 column_default: Some("uuidv7()".into()),
4993 },
4994 DbColumn {
4995 name: "verb".into(),
4996 pg_type: "\"public\".\"Method\"".into(),
4997 nullable: true,
4998 is_generated: false,
4999 column_default: None,
5000 },
5001 ],
5002 foreign_keys: vec![],
5003 indexes: vec![],
5004 checks: vec![],
5005 triggers: vec![],
5006 }],
5007 enums: vec![],
5008 domains: vec![],
5009 ..DbState::default()
5010 };
5011 let joined = diff_schema(&schema, &state).unwrap().join("\n");
5012 assert!(
5013 joined.contains(r#"USING "verb"::text::"public"."HttpMethod";"#),
5014 "the conversion should go through text:\n{joined}"
5015 );
5016 }
5017
5018 #[test]
5019 fn test_a_column_default_is_dropped_before_its_type_changes() {
5020 let mut td = simple_type("default", "Person", "Person");
5024 let mut rating = prop("rating", "int8", true);
5025 rating.default_sql = Some("'1'".into());
5026 td.properties.push(rating);
5027 let schema = SchemaDescriptor {
5028 types: vec![td],
5029 ..Default::default()
5030 };
5031 let state = DbState {
5032 schemas: vec!["default".into()],
5033 tables: vec![DbTable {
5034 schema: "default".into(),
5035 name: "Person".into(),
5036 columns: vec![
5037 DbColumn {
5038 name: "id".into(),
5039 pg_type: "uuid".into(),
5040 nullable: false,
5041 is_generated: false,
5042 column_default: Some("uuidv7()".into()),
5043 },
5044 DbColumn {
5045 name: "rating".into(),
5046 pg_type: "text".into(),
5047 nullable: true,
5048 is_generated: false,
5049 column_default: Some("'x'::text".into()),
5050 },
5051 ],
5052 foreign_keys: vec![],
5053 indexes: vec![],
5054 checks: vec![],
5055 triggers: vec![],
5056 }],
5057 enums: vec![],
5058 domains: vec![],
5059 ..DbState::default()
5060 };
5061 let joined = diff_schema(&schema, &state).unwrap().join("\n");
5062 let Some(drop) = joined.find(r#"ALTER COLUMN "rating" DROP DEFAULT;"#) else {
5063 panic!("the default should be dropped:\n{joined}");
5064 };
5065 let Some(retype) = joined.find(r#"ALTER COLUMN "rating" TYPE int8"#) else {
5066 panic!("the type should change:\n{joined}");
5067 };
5068 assert!(drop < retype, "the drop must come first:\n{joined}");
5069 }
5070
5071 #[test]
5072 fn test_property_type_change_surfaces_a_required_cast_expression_step() {
5073 let mut td = simple_type("default", "Person", "Person");
5074 td.properties.push(prop("rating", "int8", true));
5075 let schema = SchemaDescriptor {
5076 types: vec![td],
5077 scalars: vec![],
5078 enums: vec![],
5079 named_tuples: vec![],
5080 globals: vec![],
5081 functions: vec![],
5082 aliases: vec![],
5083 channels: vec![],
5084 ..Default::default()
5085 };
5086 let state = DbState {
5087 schemas: vec!["default".into()],
5088 tables: vec![DbTable {
5089 schema: "default".into(),
5090 name: "Person".into(),
5091 columns: vec![
5092 DbColumn {
5093 name: "id".into(),
5094 pg_type: "uuid".into(),
5095 nullable: false,
5096 is_generated: false,
5097 column_default: Some("uuidv7()".into()),
5098 },
5099 DbColumn {
5100 name: "name".into(),
5101 pg_type: "text".into(),
5102 nullable: true,
5103 is_generated: false,
5104 column_default: None,
5105 },
5106 DbColumn {
5107 name: "rating".into(),
5108 pg_type: "text".into(),
5109 nullable: true,
5110 is_generated: false,
5111 column_default: None,
5112 },
5113 ],
5114 foreign_keys: vec![],
5115 indexes: vec![],
5116 checks: vec![],
5117 triggers: vec![],
5118 }],
5119 enums: vec![],
5120 domains: vec![],
5121 ..DbState::default()
5122 };
5123
5124 let steps = diff_schema_steps(&schema, &state, &HashMap::new()).unwrap();
5125 let step = steps
5126 .iter()
5127 .find(|s| matches!(&s.op_key, OpKey::Table(m, t) if m == "default" && t == "Person"))
5128 .expect("expected an alter step for Person");
5129
5130 assert_eq!(step.required_input.len(), 1, "got: {:?}", step.required_input);
5131 let input = &step.required_input[0];
5132 assert_eq!(input.placeholder, "cast_expr__rating");
5133 assert_eq!(input.default_expr, "\"rating\"::int8");
5134 assert_eq!(input.type_name, "default::Person");
5135
5136 let placeholder_token = format!("\\({})", input.placeholder);
5137 assert!(
5138 step.ddl.iter().any(|op| op.sql.contains(&placeholder_token)),
5139 "expected the placeholder token in the step's DDL, got: {:?}",
5140 step.ddl.iter().map(|op| &op.sql).collect::<Vec<_>>()
5141 );
5142 }
5143
5144 #[test]
5145 fn test_equivalent_base_type_spelling_is_not_a_diff() {
5146 let mut td = simple_type("default", "Person", "Person");
5152 td.properties.push(prop("age", "int8", true));
5153 let schema = SchemaDescriptor {
5154 types: vec![td],
5155 scalars: vec![],
5156 enums: vec![],
5157 named_tuples: vec![],
5158 globals: vec![],
5159 functions: vec![],
5160 aliases: vec![],
5161 channels: vec![],
5162 ..Default::default()
5163 };
5164 let state = DbState {
5165 schemas: vec!["default".into()],
5166 tables: vec![DbTable {
5167 schema: "default".into(),
5168 name: "Person".into(),
5169 columns: vec![
5170 DbColumn {
5171 name: "id".into(),
5172 pg_type: "uuid".into(),
5173 nullable: false,
5174 is_generated: false,
5175 column_default: Some("uuidv7()".into()),
5176 },
5177 DbColumn {
5178 name: "name".into(),
5179 pg_type: "text".into(),
5180 nullable: true,
5181 is_generated: false,
5182 column_default: None,
5183 },
5184 DbColumn {
5185 name: "age".into(),
5186 pg_type: "bigint".into(),
5187 nullable: true,
5188 is_generated: false,
5189 column_default: None,
5190 },
5191 ],
5192 foreign_keys: vec![],
5193 indexes: vec![],
5194 checks: vec![],
5195 triggers: vec!["pylon_cache_invalidate".into()],
5196 }],
5197 enums: vec![],
5198 domains: vec![],
5199 ..DbState::default()
5200 };
5201 let ops = diff_schema(&schema, &state).unwrap();
5202 assert!(
5203 ops.iter().all(|op| !op.contains("ALTER COLUMN")),
5204 "expected no ALTER COLUMN ops, got: {:?}",
5205 ops
5206 );
5207 }
5208
5209 #[test]
5210 fn test_check_the_schema_no_longer_declares_is_dropped() {
5211 let mut person = simple_type("default", "Person", "Person");
5214 person.properties = vec![prop("id", "uuid", false)];
5215 let schema = SchemaDescriptor {
5216 types: vec![person],
5217 ..SchemaDescriptor::default()
5218 };
5219 let state = DbState {
5220 tables: vec![DbTable {
5221 schema: "default".into(),
5222 name: "Person".into(),
5223 columns: vec![DbColumn {
5224 name: "id".into(),
5225 pg_type: "uuid".into(),
5226 nullable: false,
5227 is_generated: false,
5228 column_default: None,
5229 }],
5230 foreign_keys: vec![],
5231 indexes: vec![],
5232 checks: vec![
5233 DbCheck {
5234 constraint_name: "Person_name_deadbeef_check".into(),
5235 },
5236 DbCheck {
5237 constraint_name: "a_hand_written_check".into(),
5238 },
5239 ],
5240 triggers: vec![],
5241 }],
5242 ..DbState::default()
5243 };
5244 let ops = diff_schema_ops(&schema, &state).unwrap();
5245 let joined = ops.iter().map(|op| op.sql.as_str()).collect::<Vec<_>>().join("\n");
5246 assert!(
5247 joined.contains("DROP CONSTRAINT IF EXISTS \"Person_name_deadbeef_check\""),
5248 "got:\n{joined}"
5249 );
5250 assert!(
5251 !joined.contains("a_hand_written_check"),
5252 "a constraint Pylon did not name is left alone:\n{joined}"
5253 );
5254 }
5255
5256 #[test]
5257 fn test_changed_scalar_domain_checks_are_reconciled() {
5258 use crate::schema::ScalarDescriptor;
5259 let schema = SchemaDescriptor {
5260 scalars: vec![ScalarDescriptor {
5261 name: "EmailStr".into(),
5262 module: "default".into(),
5263 base: "Str".into(),
5264 pg_type: "text".into(),
5265 check_constraints: vec!["char_length(VALUE) <= 320".into()],
5266 is_sequence: false,
5267 }],
5268 ..SchemaDescriptor::default()
5269 };
5270 let state = DbState {
5271 domains: vec![DbDomain {
5272 schema: "default".into(),
5273 name: "EmailStr".into(),
5274 checks: vec!["EmailStr_stale000_check".into()],
5275 }],
5276 ..DbState::default()
5277 };
5278 let ops = diff_schema_ops(&schema, &state).unwrap();
5279 let joined = ops.iter().map(|op| op.sql.as_str()).collect::<Vec<_>>().join("\n");
5280 assert!(joined.contains("DROP CONSTRAINT"), "the old set is cleared:\n{joined}");
5281 assert!(
5282 joined.contains("ADD CONSTRAINT") && joined.contains("char_length(VALUE) <= 320"),
5283 "the new set is added:\n{joined}"
5284 );
5285 }
5286
5287 #[test]
5288 fn test_demoting_an_interface_to_a_mixin_drops_its_view() {
5289 let mut individual = simple_type("default", "Individual", "Individual");
5292 individual.properties = vec![prop("id", "uuid", false)];
5293 let schema = SchemaDescriptor {
5294 types: vec![individual],
5295 ..SchemaDescriptor::default()
5296 };
5297 let state = DbState {
5298 tables: vec![],
5299 views: vec![DbView {
5300 schema: "public".into(),
5301 name: "Account".into(),
5302 body_hash: "whatever".into(),
5303 }],
5304 ..DbState::default()
5305 };
5306 let ops = diff_schema_ops(&schema, &state).unwrap();
5307 let joined = ops.iter().map(|op| op.sql.as_str()).collect::<Vec<_>>().join("\n");
5308 assert!(
5309 joined.contains("DROP VIEW IF EXISTS \"public\".\"Account\""),
5310 "got:\n{joined}"
5311 );
5312 }
5313
5314 #[test]
5315 fn test_registered_scalar_domain_adoption_drops_and_recreates_dependent_interface_view() {
5316 use crate::schema::ScalarDescriptor;
5322
5323 let mut account = simple_type("default", "Account", "Account");
5324 account.abstract_ = true;
5325 account.materialized = true;
5326 account.properties = vec![prop("id", "uuid", false), prop("email", "text", false)];
5327 account.properties[1].column_type = Some("\"public\".\"Email\"".into());
5328
5329 let mut individual = simple_type("default", "Individual", "Individual");
5330 individual.interfaces = vec!["default::Account".into()];
5331 individual.properties = vec![prop("id", "uuid", false), prop("email", "text", false)];
5332 individual.properties[1].column_type = Some("\"public\".\"Email\"".into());
5333
5334 let schema = SchemaDescriptor {
5335 types: vec![account, individual],
5336 scalars: vec![ScalarDescriptor {
5337 name: "Email".into(),
5338 module: "default".into(),
5339 base: "Str".into(),
5340 pg_type: "text".into(),
5341 check_constraints: vec!["value ~ '@'".into()],
5342 is_sequence: false,
5343 }],
5344 enums: vec![],
5345 named_tuples: vec![],
5346 globals: vec![],
5347 functions: vec![],
5348 aliases: vec![],
5349 channels: vec![],
5350 ..Default::default()
5351 };
5352
5353 let view_ddl = crate::export::interface_view_ddl_with_names(&schema)
5358 .into_iter()
5359 .find(|(_, n, _)| n == "Account")
5360 .unwrap()
5361 .2;
5362
5363 let state = DbState {
5364 schemas: vec!["default".into()],
5365 tables: vec![DbTable {
5366 schema: "default".into(),
5367 name: "Individual".into(),
5368 columns: vec![
5369 DbColumn {
5370 name: "id".into(),
5371 pg_type: "uuid".into(),
5372 nullable: false,
5373 is_generated: false,
5374 column_default: Some("uuidv7()".into()),
5375 },
5376 DbColumn {
5377 name: "email".into(),
5378 pg_type: "text".into(),
5379 nullable: false,
5380 is_generated: false,
5381 column_default: None,
5382 },
5383 ],
5384 foreign_keys: vec![],
5385 indexes: vec![],
5386 checks: vec![],
5387 triggers: vec!["pylon_cache_invalidate".into()],
5388 }],
5389 views: vec![DbView {
5390 schema: "default".into(),
5391 name: "Account".into(),
5392 body_hash: ddl_hash(&view_ddl),
5393 }],
5394 enums: vec![],
5395 domains: vec![],
5396 ..DbState::default()
5397 };
5398
5399 let ops = diff_schema_ops(&schema, &state).unwrap();
5403 let joined = ops.iter().map(|op| op.sql.as_str()).collect::<Vec<_>>().join("\n");
5404 let drop_pos = joined
5405 .find("DROP VIEW IF EXISTS \"public\".\"Account\"")
5406 .unwrap_or_else(|| panic!("missing DROP VIEW; got:\n{joined}"));
5407 let alter_pos = joined
5408 .find("ALTER TABLE \"public\".\"Individual\" ALTER COLUMN \"email\" TYPE \"public\".\"Email\"")
5409 .unwrap_or_else(|| panic!("missing ALTER COLUMN TYPE; got:\n{joined}"));
5410 let create_pos = joined
5411 .rfind("CREATE VIEW \"public\".\"Account\"")
5412 .unwrap_or_else(|| panic!("missing CREATE VIEW; got:\n{joined}"));
5413 assert!(drop_pos < alter_pos, "DROP VIEW must precede the ALTER; got:\n{joined}");
5414 assert!(
5415 alter_pos < create_pos,
5416 "CREATE VIEW must follow the ALTER; got:\n{joined}"
5417 );
5418 assert_eq!(
5419 joined.matches("CREATE VIEW \"public\".\"Account\"").count(),
5420 1,
5421 "view must be recreated exactly once, not duplicated by Phase 10; got:\n{joined}"
5422 );
5423 }
5424
5425 fn exclusive_email_account_schema(implementor_names: &[&str]) -> SchemaDescriptor {
5426 let mut account = simple_type("default", "Account", "Account");
5427 account.abstract_ = true;
5428 account.materialized = true;
5429 account.properties = vec![prop("id", "uuid", false), prop("email", "text", false)];
5430 account.properties[1].is_exclusive = true;
5431
5432 let mut types = vec![account];
5433 for name in implementor_names {
5434 let mut t = simple_type("default", name, name);
5435 t.interfaces = vec!["default::Account".into()];
5436 t.properties = vec![prop("id", "uuid", false), prop("email", "text", false)];
5437 t.properties[1].is_exclusive = true;
5438 types.push(t);
5439 }
5440 SchemaDescriptor {
5441 types,
5442 scalars: vec![],
5443 enums: vec![],
5444 named_tuples: vec![],
5445 globals: vec![],
5446 functions: vec![],
5447 aliases: vec![],
5448 channels: vec![],
5449 ..Default::default()
5450 }
5451 }
5452
5453 #[test]
5454 fn test_new_implementor_added_to_existing_interface_gets_exclusive_triggers_retroactively() {
5455 let schema = exclusive_email_account_schema(&["Individual", "Organization"]);
5461 let state = DbState {
5462 schemas: vec!["default".into()],
5463 tables: vec![DbTable {
5464 schema: "default".into(),
5465 name: "Individual".into(),
5466 columns: vec![
5467 DbColumn {
5468 name: "id".into(),
5469 pg_type: "uuid".into(),
5470 nullable: false,
5471 is_generated: false,
5472 column_default: Some("uuidv7()".into()),
5473 },
5474 DbColumn {
5475 name: "email".into(),
5476 pg_type: "text".into(),
5477 nullable: false,
5478 is_generated: false,
5479 column_default: None,
5480 },
5481 ],
5482 foreign_keys: vec![],
5483 indexes: vec![],
5484 checks: vec![],
5485 triggers: vec![
5486 "pylon_cache_invalidate".into(),
5487 "_excl_Account_email_ins".into(),
5488 "_excl_Account_email_upd".into(),
5489 ],
5490 }],
5491 enums: vec![],
5492 domains: vec![],
5493 ..DbState::default()
5494 };
5495
5496 let ops = diff_schema_ops(&schema, &state).unwrap();
5497 let joined = ops.iter().map(|op| op.sql.as_str()).collect::<Vec<_>>().join("\n");
5498 assert!(
5499 joined.contains(
5500 "CREATE CONSTRAINT TRIGGER \"_excl_Account_email_ins\"\nAFTER INSERT ON \"public\".\"Organization\""
5501 ),
5502 "the new implementor must get the exclusive trigger; got:\n{joined}"
5503 );
5504 assert!(
5505 !joined.contains("ON \"public\".\"Individual\""),
5506 "the already-migrated implementor's existing triggers must not be re-emitted; got:\n{joined}"
5507 );
5508 }
5509
5510 #[test]
5511 fn test_the_shared_exclusive_guard_function_precedes_every_implementors_trigger() {
5512 let mut schema = exclusive_email_account_schema(&["Individual", "Organization"]);
5518 let individual = schema
5519 .types
5520 .iter_mut()
5521 .find(|t| t.name == "Individual")
5522 .expect("Individual is in the schema");
5523 individual.links.push(LinkDescriptor {
5524 name: "employer".into(),
5525 target: "default::Organization".into(),
5526 nullable: false,
5527 through: None,
5528 description: None,
5529 default_pyql: None,
5530 is_exclusive: false,
5531 is_readonly: false,
5532 rewrites: vec![],
5533 on_delete: vec![],
5534 });
5535
5536 let ops = diff_schema_ops(&schema, &DbState::default()).unwrap();
5537 let joined = ops.iter().map(|op| op.sql.as_str()).collect::<Vec<_>>().join("\n");
5538 let function_pos = joined
5539 .find("CREATE OR REPLACE FUNCTION \"public\".\"_excl_Account_email\"")
5540 .unwrap_or_else(|| panic!("missing the guard function; got:\n{joined}"));
5541 let first_trigger_pos = joined
5542 .find("CREATE CONSTRAINT TRIGGER \"_excl_Account_email_ins\"")
5543 .unwrap_or_else(|| panic!("missing the exclusive triggers; got:\n{joined}"));
5544 assert!(
5545 function_pos < first_trigger_pos,
5546 "the guard function must be defined before the first trigger naming it; got:\n{joined}"
5547 );
5548 assert_eq!(
5549 joined
5550 .matches("CREATE OR REPLACE FUNCTION \"public\".\"_excl_Account_email\"")
5551 .count(),
5552 1,
5553 "the guard function must be emitted exactly once; got:\n{joined}"
5554 );
5555 }
5556
5557 #[test]
5558 fn test_removing_exclusivity_drops_the_cross_table_triggers() {
5559 let mut schema = exclusive_email_account_schema(&["Individual"]);
5564 for t in &mut schema.types {
5565 for p in &mut t.properties {
5566 if p.name == "email" {
5567 p.is_exclusive = false;
5568 }
5569 }
5570 }
5571 let state = DbState {
5572 schemas: vec!["default".into()],
5573 tables: vec![DbTable {
5574 schema: "default".into(),
5575 name: "Individual".into(),
5576 columns: vec![
5577 DbColumn {
5578 name: "id".into(),
5579 pg_type: "uuid".into(),
5580 nullable: false,
5581 is_generated: false,
5582 column_default: Some("uuidv7()".into()),
5583 },
5584 DbColumn {
5585 name: "email".into(),
5586 pg_type: "text".into(),
5587 nullable: false,
5588 is_generated: false,
5589 column_default: None,
5590 },
5591 ],
5592 foreign_keys: vec![],
5593 indexes: vec![],
5594 checks: vec![],
5595 triggers: vec![
5596 "pylon_cache_invalidate".into(),
5597 "_excl_Account_email_ins".into(),
5598 "_excl_Account_email_upd".into(),
5599 ],
5600 }],
5601 enums: vec![],
5602 domains: vec![],
5603 ..DbState::default()
5604 };
5605
5606 let ops = diff_schema_ops(&schema, &state).unwrap();
5607 let joined = ops.iter().map(|op| op.sql.as_str()).collect::<Vec<_>>().join("\n");
5608 assert!(
5609 joined.contains("DROP TRIGGER IF EXISTS \"_excl_Account_email_ins\" ON \"public\".\"Individual\""),
5610 "got:\n{joined}"
5611 );
5612 assert!(
5613 joined.contains("DROP TRIGGER IF EXISTS \"_excl_Account_email_upd\" ON \"public\".\"Individual\""),
5614 "got:\n{joined}"
5615 );
5616 assert!(
5617 !joined.contains("DROP TRIGGER IF EXISTS \"pylon_cache_invalidate\""),
5618 "unrelated triggers must not be touched; got:\n{joined}"
5619 );
5620 }
5621
5622 #[test]
5623 fn test_new_enum() {
5624 let schema = SchemaDescriptor {
5625 types: vec![],
5626 scalars: vec![],
5627 enums: vec![EnumDescriptor {
5628 name: "Status".into(),
5629 module: "default".into(),
5630 members: vec!["Active".into(), "Inactive".into()],
5631 }],
5632 named_tuples: vec![],
5633 globals: vec![],
5634 functions: vec![],
5635 aliases: vec![],
5636 channels: vec![],
5637 ..Default::default()
5638 };
5639 let ops = diff_schema(&schema, &empty_state()).unwrap();
5640 let joined = ops.join("\n");
5641 assert!(
5642 joined.contains("CREATE TYPE \"public\".\"Status\" AS ENUM"),
5643 "got:\n{joined}"
5644 );
5645 }
5646
5647 #[test]
5648 fn test_drop_table() {
5649 let schema = SchemaDescriptor {
5650 types: vec![],
5651 scalars: vec![],
5652 enums: vec![],
5653 named_tuples: vec![],
5654 globals: vec![],
5655 functions: vec![],
5656 aliases: vec![],
5657 channels: vec![],
5658 ..Default::default()
5659 };
5660 let state = DbState {
5661 schemas: vec!["default".into()],
5662 tables: vec![DbTable {
5663 schema: "default".into(),
5664 name: "OldType".into(),
5665 columns: vec![],
5666 foreign_keys: vec![],
5667 indexes: vec![],
5668 checks: vec![],
5669 triggers: vec![],
5670 }],
5671 enums: vec![],
5672 domains: vec![],
5673 ..DbState::default()
5674 };
5675 let ops = diff_schema(&schema, &state).unwrap();
5676 let joined = ops.join("\n");
5677 assert!(
5678 joined.contains("DROP TABLE IF EXISTS \"public\".\"OldType\" CASCADE"),
5679 "got:\n{joined}"
5680 );
5681 }
5682
5683 #[test]
5684 fn test_index_on_existing_table_is_concurrently() {
5685 use crate::schema::VectorIndexDescriptor;
5686 let mut td = simple_type("default", "Post", "Post");
5687 td.vector_indexes.push(VectorIndexDescriptor {
5688 index_name: None,
5689 pointers: vec!["name".into()],
5690 model: "test".into(),
5691 metric: "cosine".into(),
5692 dimensions: 1536,
5693 });
5694 let schema = SchemaDescriptor {
5695 types: vec![td],
5696 scalars: vec![],
5697 enums: vec![],
5698 named_tuples: vec![],
5699 globals: vec![],
5700 functions: vec![],
5701 aliases: vec![],
5702 channels: vec![],
5703 ..Default::default()
5704 };
5705 let state = DbState {
5707 schemas: vec!["default".into()],
5708 tables: vec![DbTable {
5709 schema: "default".into(),
5710 name: "Post".into(),
5711 columns: vec![
5712 DbColumn {
5713 name: "id".into(),
5714 pg_type: "uuid".into(),
5715 nullable: false,
5716 is_generated: false,
5717 column_default: Some("uuidv7()".into()),
5718 },
5719 DbColumn {
5720 name: "name".into(),
5721 pg_type: "text".into(),
5722 nullable: true,
5723 is_generated: false,
5724 column_default: None,
5725 },
5726 ],
5727 foreign_keys: vec![],
5728 indexes: vec![],
5729 checks: vec![],
5730 triggers: vec![],
5731 }],
5732 enums: vec![],
5733 domains: vec![],
5734 ..DbState::default()
5735 };
5736 let ops = diff_schema_ops(&schema, &state).unwrap();
5737 let idx_op = ops.iter().find(|op| op.sql.contains("hnsw")).unwrap();
5738 assert!(
5739 idx_op.non_transactional,
5740 "index on pre-existing table should be non-transactional"
5741 );
5742 assert!(
5743 idx_op.sql.contains("CONCURRENTLY"),
5744 "should use CONCURRENTLY: {}",
5745 idx_op.sql
5746 );
5747 }
5748
5749 #[test]
5750 fn test_required_extensions_empty_without_vector_indexes() {
5751 let schema = SchemaDescriptor {
5752 types: vec![simple_type("default", "Post", "Post")],
5753 scalars: vec![],
5754 enums: vec![],
5755 named_tuples: vec![],
5756 globals: vec![],
5757 functions: vec![],
5758 aliases: vec![],
5759 channels: vec![],
5760 ..Default::default()
5761 };
5762 assert!(required_extensions(&schema).is_empty());
5763 }
5764
5765 #[test]
5766 fn test_missing_extension_ddl_when_vector_index_present_and_not_yet_installed() {
5767 use crate::schema::VectorIndexDescriptor;
5768 let mut td = simple_type("default", "Post", "Post");
5769 td.vector_indexes.push(VectorIndexDescriptor {
5770 index_name: None,
5771 pointers: vec!["name".into()],
5772 model: "test".into(),
5773 metric: "cosine".into(),
5774 dimensions: 1536,
5775 });
5776 let schema = SchemaDescriptor {
5777 types: vec![td],
5778 scalars: vec![],
5779 enums: vec![],
5780 named_tuples: vec![],
5781 globals: vec![],
5782 functions: vec![],
5783 aliases: vec![],
5784 channels: vec![],
5785 ..Default::default()
5786 };
5787 assert_eq!(required_extensions(&schema), vec!["vector"]);
5788
5789 let ddl = missing_extension_ddl(&schema, &DbState::default());
5790 assert_eq!(ddl, vec!["CREATE EXTENSION IF NOT EXISTS \"vector\";".to_string()]);
5791
5792 let already_installed = DbState {
5793 extensions: vec!["vector".into()],
5794 ..DbState::default()
5795 };
5796 assert!(missing_extension_ddl(&schema, &already_installed).is_empty());
5797 }
5798
5799 #[test]
5800 fn test_index_on_new_table_is_transactional() {
5801 use crate::schema::VectorIndexDescriptor;
5802 let mut td = simple_type("default", "Post", "Post");
5803 td.vector_indexes.push(VectorIndexDescriptor {
5804 index_name: None,
5805 pointers: vec!["name".into()],
5806 model: "test".into(),
5807 metric: "cosine".into(),
5808 dimensions: 1536,
5809 });
5810 let schema = SchemaDescriptor {
5811 types: vec![td],
5812 scalars: vec![],
5813 enums: vec![],
5814 named_tuples: vec![],
5815 globals: vec![],
5816 functions: vec![],
5817 aliases: vec![],
5818 channels: vec![],
5819 ..Default::default()
5820 };
5821 let ops = diff_schema_ops(&schema, &empty_state()).unwrap();
5823 let idx_op = ops.iter().find(|op| op.sql.contains("hnsw")).unwrap();
5824 assert!(!idx_op.non_transactional, "index on new table should be transactional");
5825 assert!(
5826 !idx_op.sql.contains("CONCURRENTLY"),
5827 "should NOT use CONCURRENTLY: {}",
5828 idx_op.sql
5829 );
5830 }
5831
5832 fn sequence_scalar(module: &str, name: &str) -> crate::schema::ScalarDescriptor {
5833 crate::schema::ScalarDescriptor {
5834 name: name.into(),
5835 module: module.into(),
5836 base: "Sequence".into(),
5837 pg_type: "int8".into(),
5838 check_constraints: vec![],
5839 is_sequence: true,
5840 }
5841 }
5842
5843 #[test]
5844 fn test_new_sequence_creates_sequence_and_domain() {
5845 let schema = SchemaDescriptor {
5846 types: vec![],
5847 enums: vec![],
5848 named_tuples: vec![],
5849 globals: vec![],
5850 functions: vec![],
5851 aliases: vec![],
5852 channels: vec![],
5853 scalars: vec![sequence_scalar("default", "OrderNumber")],
5854 ..Default::default()
5855 };
5856 let ops = diff_schema(&schema, &empty_state()).unwrap();
5857 let joined = ops.join("\n");
5858 assert!(
5859 joined.contains("CREATE SEQUENCE IF NOT EXISTS \"public\".\"OrderNumber_seq\""),
5860 "got:\n{joined}"
5861 );
5862 assert!(
5863 joined.contains("CREATE DOMAIN \"public\".\"OrderNumber\" AS int8"),
5864 "got:\n{joined}"
5865 );
5866 let seq_pos = joined.find("CREATE SEQUENCE").unwrap();
5868 let dom_pos = joined.find("CREATE DOMAIN").unwrap();
5869 assert!(seq_pos < dom_pos, "sequence must be created before domain");
5870 }
5871
5872 #[test]
5873 fn test_no_ops_sequence_already_exists() {
5874 let schema = SchemaDescriptor {
5875 types: vec![],
5876 enums: vec![],
5877 named_tuples: vec![],
5878 globals: vec![],
5879 functions: vec![],
5880 aliases: vec![],
5881 channels: vec![],
5882 scalars: vec![sequence_scalar("default", "OrderNumber")],
5883 ..Default::default()
5884 };
5885 let state = DbState {
5886 schemas: vec!["default".into()],
5887 domains: vec![DbDomain {
5888 schema: "default".into(),
5889 name: "OrderNumber".into(),
5890 checks: vec![],
5891 }],
5892 sequences: vec![DbSequence {
5893 schema: "default".into(),
5894 name: "OrderNumber_seq".into(),
5895 }],
5896 ..DbState::default()
5897 };
5898 let ops = diff_schema(&schema, &state).unwrap();
5899 assert!(
5900 ops.is_empty(),
5901 "expected no ops when sequence and domain exist, got: {:?}",
5902 ops
5903 );
5904 }
5905
5906 #[test]
5907 fn test_drop_removed_sequence() {
5908 let schema = SchemaDescriptor {
5909 types: vec![],
5910 scalars: vec![],
5911 enums: vec![],
5912 named_tuples: vec![],
5913 globals: vec![],
5914 functions: vec![],
5915 aliases: vec![],
5916 channels: vec![],
5917 ..Default::default()
5918 };
5919 let state = DbState {
5920 schemas: vec!["default".into()],
5921 domains: vec![DbDomain {
5922 schema: "default".into(),
5923 name: "OrderNumber".into(),
5924 checks: vec![],
5925 }],
5926 sequences: vec![DbSequence {
5927 schema: "default".into(),
5928 name: "OrderNumber_seq".into(),
5929 }],
5930 ..DbState::default()
5931 };
5932 let ops = diff_schema(&schema, &state).unwrap();
5933 let joined = ops.join("\n");
5934 assert!(
5935 joined.contains("DROP DOMAIN IF EXISTS \"public\".\"OrderNumber\""),
5936 "got:\n{joined}"
5937 );
5938 assert!(
5939 joined.contains("DROP SEQUENCE IF EXISTS \"public\".\"OrderNumber_seq\""),
5940 "got:\n{joined}"
5941 );
5942 }
5943
5944 #[test]
5947 fn test_diff_schema_steps_groups_multiple_column_changes_into_one_alter_step() {
5948 let mut person = simple_type("default", "Person", "Person");
5949 person.properties.push(prop("nickname", "text", true));
5950 person.properties.push(prop("age", "int8", true));
5951 let schema = SchemaDescriptor {
5952 types: vec![person],
5953 scalars: vec![],
5954 enums: vec![],
5955 named_tuples: vec![],
5956 globals: vec![],
5957 functions: vec![],
5958 aliases: vec![],
5959 channels: vec![],
5960 ..Default::default()
5961 };
5962 let state = DbState {
5963 schemas: vec!["default".into()],
5964 tables: vec![DbTable {
5965 schema: "default".into(),
5966 name: "Person".into(),
5967 columns: vec![
5968 DbColumn {
5969 name: "id".into(),
5970 pg_type: "uuid".into(),
5971 nullable: false,
5972 is_generated: false,
5973 column_default: Some("uuidv7()".into()),
5974 },
5975 DbColumn {
5976 name: "name".into(),
5977 pg_type: "text".into(),
5978 nullable: true,
5979 is_generated: false,
5980 column_default: None,
5981 },
5982 ],
5983 foreign_keys: vec![],
5984 indexes: vec![],
5985 checks: vec![],
5986 triggers: vec!["pylon_cache_invalidate".into()],
5987 }],
5988 enums: vec![],
5989 domains: vec![],
5990 ..DbState::default()
5991 };
5992
5993 let steps = diff_schema_steps(&schema, &state, &HashMap::new()).unwrap();
5994 let table_steps: Vec<&MigrationStep> = steps
5995 .iter()
5996 .filter(|s| matches!(&s.op_key, OpKey::Table(m, t) if m == "default" && t == "Person"))
5997 .collect();
5998 assert_eq!(
5999 table_steps.len(),
6000 1,
6001 "two new columns on the same table must produce one step, got: {:?}",
6002 steps.iter().map(|s| &s.prompt).collect::<Vec<_>>()
6003 );
6004 assert_eq!(table_steps[0].verb, Verb::Alter);
6005 assert_eq!(table_steps[0].prompt, "did you alter object type 'default::Person'?");
6006 assert_eq!(
6007 table_steps[0].ddl.len(),
6008 2,
6009 "expected one ADD COLUMN per new property, got: {:?}",
6010 table_steps[0].ddl.iter().map(|d| &d.sql).collect::<Vec<_>>()
6011 );
6012 }
6013
6014 #[test]
6015 fn test_diff_schema_steps_new_table_is_one_create_step_including_its_trigger() {
6016 let schema = SchemaDescriptor {
6017 types: vec![simple_type("catalog", "Product", "Product")],
6018 scalars: vec![],
6019 enums: vec![],
6020 named_tuples: vec![],
6021 globals: vec![],
6022 functions: vec![],
6023 aliases: vec![],
6024 channels: vec![],
6025 ..Default::default()
6026 };
6027 let steps = diff_schema_steps(&schema, &empty_state(), &HashMap::new()).unwrap();
6028 let table_steps: Vec<&MigrationStep> = steps
6029 .iter()
6030 .filter(|s| matches!(&s.op_key, OpKey::Table(m, t) if m == "catalog" && t == "Product"))
6031 .collect();
6032 assert_eq!(
6033 table_steps.len(),
6034 1,
6035 "got steps: {:?}",
6036 steps.iter().map(|s| &s.prompt).collect::<Vec<_>>()
6037 );
6038 assert_eq!(table_steps[0].verb, Verb::Create);
6039 assert_eq!(table_steps[0].prompt, "did you create object type 'catalog::Product'?");
6040
6041 let joined: String = table_steps[0]
6044 .ddl
6045 .iter()
6046 .map(|d| d.sql.as_str())
6047 .collect::<Vec<_>>()
6048 .join("\n");
6049 assert!(
6050 joined.contains("CREATE TABLE IF NOT EXISTS \"catalog\".\"Product\""),
6051 "got:\n{joined}"
6052 );
6053 assert!(joined.contains("pylon_cache_invalidate"), "got:\n{joined}");
6054 }
6055
6056 #[test]
6057 fn test_guidance_bans_a_rejected_type_rename_candidate() {
6058 let schema = SchemaDescriptor {
6059 types: vec![simple_type("default", "Customer", "Customer")],
6060 scalars: vec![],
6061 enums: vec![],
6062 named_tuples: vec![],
6063 globals: vec![],
6064 functions: vec![],
6065 aliases: vec![],
6066 channels: vec![],
6067 ..Default::default()
6068 };
6069 let state = DbState {
6070 schemas: vec!["default".into()],
6071 tables: vec![DbTable {
6072 schema: "default".into(),
6073 name: "Person".into(),
6074 columns: vec![
6075 DbColumn {
6076 name: "id".into(),
6077 pg_type: "uuid".into(),
6078 nullable: false,
6079 is_generated: false,
6080 column_default: Some("uuidv7()".into()),
6081 },
6082 DbColumn {
6083 name: "name".into(),
6084 pg_type: "text".into(),
6085 nullable: true,
6086 is_generated: false,
6087 column_default: None,
6088 },
6089 ],
6090 foreign_keys: vec![],
6091 indexes: vec![],
6092 checks: vec![],
6093 triggers: vec![],
6094 }],
6095 enums: vec![],
6096 domains: vec![],
6097 ..DbState::default()
6098 };
6099
6100 let candidates = detect_type_renames(&schema, &state, &Guidance::default());
6101 assert_eq!(
6102 candidates.len(),
6103 1,
6104 "expected Person -> Customer to be proposed as a rename"
6105 );
6106
6107 let mut guidance = Guidance::default();
6108 guidance.banned_type_renames.insert((
6109 "default".to_string(),
6110 "Person".to_string(),
6111 "default".to_string(),
6112 "Customer".to_string(),
6113 ));
6114 let candidates = detect_type_renames(&schema, &state, &guidance);
6115 assert!(
6116 candidates.is_empty(),
6117 "a banned rename candidate must not be re-proposed"
6118 );
6119 }
6120}