1use std::collections::{HashMap, HashSet};
28
29use serde::{Deserialize, Serialize};
30
31use crate::schema::{SchemaDescriptor, SearchBackend, TypeDescriptor};
32
33#[derive(Debug, Default, Clone, Serialize, Deserialize)]
37pub struct DbState {
38 #[serde(default)]
40 pub schemas: Vec<String>,
41 #[serde(default)]
42 pub tables: Vec<DbTable>,
43 #[serde(default)]
44 pub enums: Vec<DbEnum>,
45 #[serde(default)]
46 pub domains: Vec<DbDomain>,
47 #[serde(default)]
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 finish(self) -> Vec<MigrationStep> {
1107 let Self { order, mut drafts } = self;
1108 order
1109 .into_iter()
1110 .map(|key| {
1111 let (verb, object_desc, ddl, required_input) = drafts.remove(&key).unwrap();
1112 let prompt = format!("did you {} {}?", verb.as_str(), object_desc);
1113 MigrationStep {
1114 prompt,
1115 verb,
1116 object_desc,
1117 ddl,
1118 op_key: key,
1119 required_input,
1120 }
1121 })
1122 .collect()
1123 }
1124}
1125
1126#[derive(Debug, Default, Clone)]
1131pub struct Guidance {
1132 pub banned_type_renames: HashSet<(String, String, String, String)>,
1135 pub banned_col_renames: HashSet<(String, String, String, String)>,
1138}
1139
1140pub fn diff_schema(target: &SchemaDescriptor, current: &DbState) -> Result<Vec<String>, String> {
1146 Ok(flatten_ops(diff_inner(target, current, false, &HashMap::new())?)
1147 .into_iter()
1148 .map(|op| op.sql)
1149 .collect())
1150}
1151
1152pub fn diff_schema_ops(target: &SchemaDescriptor, current: &DbState) -> Result<Vec<DiffOp>, String> {
1156 Ok(flatten_ops(diff_inner(target, current, true, &HashMap::new())?))
1157}
1158
1159pub fn schema_content_changed(target: &SchemaDescriptor, previous: Option<&SchemaDescriptor>) -> bool {
1195 let previous = previous.cloned().unwrap_or_default();
1196 serde_json::to_value(target).ok() != serde_json::to_value(&previous).ok()
1201}
1202
1203pub fn diff_schema_steps(
1209 target: &SchemaDescriptor,
1210 current: &DbState,
1211 fill_index: &HashMap<(String, String), HashSet<String>>,
1212) -> Result<Vec<MigrationStep>, String> {
1213 diff_inner(target, current, true, fill_index)
1214}
1215
1216fn flatten_ops(steps: Vec<MigrationStep>) -> Vec<DiffOp> {
1223 let no_overrides = HashMap::new();
1224 steps.iter().flat_map(|s| s.resolved_ddl(&no_overrides)).collect()
1225}
1226
1227pub fn diff_states(before: &DbState, after: &DbState) -> Vec<DiffOp> {
1232 diff_states_inner(before, after)
1233}
1234
1235pub fn detect_type_renames(
1241 target: &SchemaDescriptor,
1242 current: &DbState,
1243 guidance: &Guidance,
1244) -> Vec<TypeRenameCandidate> {
1245 let target_keys: HashSet<(&str, &str)> = target
1246 .types
1247 .iter()
1248 .filter(|t| !t.abstract_ && !t.junction)
1249 .map(|t| (t.module.as_str(), t.table.as_str()))
1250 .collect();
1251 let current_keys: HashSet<(&str, &str)> = current
1252 .tables
1253 .iter()
1254 .map(|t| (t.schema.as_str(), t.name.as_str()))
1255 .collect();
1256
1257 let dropped: Vec<&DbTable> = current
1258 .tables
1259 .iter()
1260 .filter(|t| !target_keys.contains(&(t.schema.as_str(), t.name.as_str())))
1261 .collect();
1262 let created: Vec<&TypeDescriptor> = target
1263 .types
1264 .iter()
1265 .filter(|t| !t.abstract_ && !t.junction)
1266 .filter(|t| !current_keys.contains(&(t.module.as_str(), t.table.as_str())))
1267 .collect();
1268
1269 if dropped.is_empty() || created.is_empty() {
1270 return vec![];
1271 }
1272
1273 let mut candidates: Vec<TypeRenameCandidate> = Vec::new();
1274 for dropped_t in &dropped {
1275 let old_cols: HashSet<&str> = dropped_t
1276 .columns
1277 .iter()
1278 .map(|c| c.name.as_str())
1279 .filter(|n| !n.starts_with("__"))
1280 .collect();
1281 for new_type in &created {
1282 let new_cols: HashSet<&str> = new_type.properties.iter().map(|p| p.name.as_str()).collect();
1283 let intersection = old_cols.intersection(&new_cols).count();
1284 let union_size = old_cols.union(&new_cols).count();
1285 if union_size == 0 {
1286 continue;
1287 }
1288 let confidence = intersection as f64 / union_size as f64;
1289 let banned = guidance.banned_type_renames.contains(&(
1290 dropped_t.schema.clone(),
1291 dropped_t.name.clone(),
1292 new_type.module.clone(),
1293 new_type.table.clone(),
1294 ));
1295 if confidence >= 0.4 && !banned {
1296 candidates.push(TypeRenameCandidate {
1297 old_module: dropped_t.schema.clone(),
1298 old_table: dropped_t.name.clone(),
1299 new_module: new_type.module.clone(),
1300 new_table: new_type.table.clone(),
1301 new_type_name: new_type.name.clone(),
1302 confidence,
1303 });
1304 }
1305 }
1306 }
1307 candidates.sort_by(|a, b| {
1308 b.confidence
1309 .partial_cmp(&a.confidence)
1310 .unwrap_or(std::cmp::Ordering::Equal)
1311 });
1312 candidates
1313}
1314
1315pub fn detect_col_renames(
1320 target: &SchemaDescriptor,
1321 current: &DbState,
1322 guidance: &Guidance,
1323) -> Vec<ColRenameCandidate> {
1324 let cur_tables: HashMap<(&str, &str), &DbTable> = current
1325 .tables
1326 .iter()
1327 .map(|t| ((t.schema.as_str(), t.name.as_str()), t))
1328 .collect();
1329
1330 let mut candidates: Vec<ColRenameCandidate> = Vec::new();
1331 for td in &target.types {
1332 if td.abstract_ || td.junction {
1333 continue;
1334 }
1335 let Some(cur) = cur_tables.get(&(td.module.as_str(), td.table.as_str())) else {
1336 continue;
1337 };
1338
1339 let target_cols: Vec<(String, String)> = td
1341 .properties
1342 .iter()
1343 .map(|p| (p.name.clone(), col_type_str(p).to_string()))
1344 .chain(
1345 td.links
1346 .iter()
1347 .filter(|l| !l.is_junction_backed())
1348 .map(|l| (format!("{}_id", l.name), "uuid".to_string())),
1349 )
1350 .collect();
1351
1352 let cur_cols: Vec<(&str, &str)> = cur
1354 .columns
1355 .iter()
1356 .filter(|c| !c.name.starts_with("__"))
1357 .map(|c| (c.name.as_str(), c.pg_type.as_str()))
1358 .collect();
1359
1360 let dropped: Vec<(&str, &str)> = cur_cols
1362 .iter()
1363 .copied()
1364 .filter(|(name, _)| !target_cols.iter().any(|(t, _)| t.as_str() == *name))
1365 .collect();
1366 let added: Vec<(&str, &str)> = target_cols
1368 .iter()
1369 .filter(|(name, _)| !cur_cols.iter().any(|&(c, _)| c == name.as_str()))
1370 .map(|(n, t)| (n.as_str(), t.as_str()))
1371 .collect();
1372
1373 if dropped.is_empty() || added.is_empty() {
1374 continue;
1375 }
1376
1377 let mut dropped_by_type: HashMap<&str, Vec<&str>> = HashMap::new();
1380 for (name, pg_type) in &dropped {
1381 dropped_by_type.entry(pg_type).or_default().push(name);
1382 }
1383 let mut added_by_type: HashMap<&str, Vec<&str>> = HashMap::new();
1384 for (name, pg_type) in &added {
1385 added_by_type.entry(pg_type).or_default().push(name);
1386 }
1387
1388 for (pg_type, dropped_names) in &dropped_by_type {
1389 if let Some(added_names) = added_by_type.get(pg_type)
1390 && dropped_names.len() == 1
1391 && added_names.len() == 1
1392 {
1393 let banned = guidance.banned_col_renames.contains(&(
1394 td.module.clone(),
1395 td.table.clone(),
1396 dropped_names[0].to_string(),
1397 added_names[0].to_string(),
1398 ));
1399 if !banned {
1400 candidates.push(ColRenameCandidate {
1401 module: td.module.clone(),
1402 table: td.table.clone(),
1403 old_col: dropped_names[0].to_string(),
1404 new_col: added_names[0].to_string(),
1405 pg_type: pg_type.to_string(),
1406 });
1407 }
1408 }
1409 }
1410 }
1411 candidates
1412}
1413
1414pub fn detect_fill_required(target: &SchemaDescriptor, current: &DbState) -> Vec<FillRequired> {
1425 let cur_tables: HashMap<(&str, &str), &DbTable> = current
1426 .tables
1427 .iter()
1428 .map(|t| ((t.schema.as_str(), t.name.as_str()), t))
1429 .collect();
1430
1431 let mut result: Vec<FillRequired> = Vec::new();
1432
1433 for td in &target.types {
1434 if td.abstract_ || td.junction {
1435 continue;
1436 }
1437 let Some(cur) = cur_tables.get(&(td.module.as_str(), td.table.as_str())) else {
1438 continue;
1439 };
1440
1441 let cur_col_map: HashMap<&str, &DbColumn> = cur.columns.iter().map(|c| (c.name.as_str(), c)).collect();
1442
1443 for p in &td.properties {
1444 if p.nullable || p.is_pk {
1445 continue;
1446 }
1447 match cur_col_map.get(p.name.as_str()) {
1448 None => {
1449 if p.default_sql.is_none() {
1451 result.push(FillRequired {
1452 module: td.module.clone(),
1453 table: td.table.clone(),
1454 column: p.name.clone(),
1455 pg_type: col_type_str(p).to_string(),
1456 type_name: td.name.clone(),
1457 is_new_column: true,
1458 default_sql: None,
1459 });
1460 }
1461 }
1462 Some(cur_col) if cur_col.nullable => {
1463 result.push(FillRequired {
1465 module: td.module.clone(),
1466 table: td.table.clone(),
1467 column: p.name.clone(),
1468 pg_type: col_type_str(p).to_string(),
1469 type_name: td.name.clone(),
1470 is_new_column: false,
1471 default_sql: p.default_sql.clone(),
1472 });
1473 }
1474 _ => {}
1475 }
1476 }
1477
1478 for l in &td.links {
1479 if l.nullable || l.is_junction_backed() {
1480 continue;
1481 }
1482 let col = format!("{}_id", l.name);
1483 match cur_col_map.get(col.as_str()) {
1484 None => {
1485 result.push(FillRequired {
1486 module: td.module.clone(),
1487 table: td.table.clone(),
1488 column: col,
1489 pg_type: "uuid".to_string(),
1490 type_name: td.name.clone(),
1491 is_new_column: true,
1492 default_sql: None,
1493 });
1494 }
1495 Some(cur_col) if cur_col.nullable => {
1496 result.push(FillRequired {
1497 module: td.module.clone(),
1498 table: td.table.clone(),
1499 column: col,
1500 pg_type: "uuid".to_string(),
1501 type_name: td.name.clone(),
1502 is_new_column: false,
1503 default_sql: None,
1504 });
1505 }
1506 _ => {}
1507 }
1508 }
1509 }
1510 result
1511}
1512
1513fn apply_renames(
1524 state: &mut DbState,
1525 type_renames: &[(String, String, String, String)],
1526 col_renames: &[(String, String, String, String)],
1527) {
1528 for (old_mod, old_table, new_mod, new_table) in type_renames {
1529 if let Some(t) = state
1530 .tables
1531 .iter_mut()
1532 .find(|t| &t.schema == old_mod && &t.name == old_table)
1533 {
1534 t.schema = new_mod.clone();
1535 t.name = new_table.clone();
1536 }
1537 }
1538 for (module, table, old_col, new_col) in col_renames {
1539 if let Some(t) = state
1540 .tables
1541 .iter_mut()
1542 .find(|t| &t.schema == module && &t.name == table)
1543 && let Some(col) = t.columns.iter_mut().find(|c| &c.name == old_col)
1544 {
1545 col.name = new_col.clone();
1546 }
1547 }
1548}
1549
1550pub fn diff_schema_ops_with_renames_and_fills(
1564 target: &SchemaDescriptor,
1565 current: &DbState,
1566 type_renames: &[(String, String, String, String)],
1567 col_renames: &[(String, String, String, String)],
1568 fills: &[(String, String, String, String)],
1569) -> Result<Vec<DiffOp>, String> {
1570 let mut ops: Vec<DiffOp> = Vec::new();
1571 let mut modified = current.clone();
1572
1573 for (old_mod, old_table, new_mod, new_table) in type_renames {
1575 if old_mod == new_mod {
1576 push_tx(
1577 &mut ops,
1578 format!("ALTER TABLE {} RENAME TO {};", qn(old_mod, old_table), qi(new_table)),
1579 );
1580 } else {
1581 push_tx(
1582 &mut ops,
1583 format!("ALTER TABLE {} SET SCHEMA {};", qn(old_mod, old_table), qi(new_mod)),
1584 );
1585 push_tx(
1586 &mut ops,
1587 format!("ALTER TABLE {} RENAME TO {};", qn(new_mod, old_table), qi(new_table)),
1588 );
1589 }
1590 }
1591 for (module, table, old_col, new_col) in col_renames {
1592 push_tx(
1593 &mut ops,
1594 format!(
1595 "ALTER TABLE {} RENAME COLUMN {} TO {};",
1596 qn(module, table),
1597 qi(old_col),
1598 qi(new_col)
1599 ),
1600 );
1601 }
1602
1603 apply_renames(&mut modified, type_renames, col_renames);
1605
1606 let mut fill_index: HashMap<(String, String), HashSet<String>> = HashMap::new();
1608 for (module, table, col, _) in fills {
1609 fill_index
1610 .entry((module.clone(), table.clone()))
1611 .or_default()
1612 .insert(col.clone());
1613 }
1614
1615 let mut diff_ops = flatten_ops(diff_inner(target, &modified, true, &fill_index)?);
1617 ops.append(&mut diff_ops);
1618
1619 for (module, table, col, fill_expr) in fills {
1621 push_tx(
1622 &mut ops,
1623 format!(
1624 "UPDATE {} SET {} = {} WHERE {} IS NULL;",
1625 qn(module, table),
1626 qi(col),
1627 fill_expr,
1628 qi(col)
1629 ),
1630 );
1631 push_tx(
1632 &mut ops,
1633 format!(
1634 "ALTER TABLE {} ALTER COLUMN {} SET NOT NULL;",
1635 qn(module, table),
1636 qi(col)
1637 ),
1638 );
1639 }
1640
1641 Ok(ops)
1642}
1643
1644pub fn diff_schema_steps_with_renames_and_fills(
1653 target: &SchemaDescriptor,
1654 current: &DbState,
1655 type_renames: &[(String, String, String, String)],
1656 col_renames: &[(String, String, String, String)],
1657 fills: &[(String, String, String, String)],
1658) -> Result<Vec<MigrationStep>, String> {
1659 let mut modified = current.clone();
1660 apply_renames(&mut modified, type_renames, col_renames);
1661
1662 let mut fill_index: HashMap<(String, String), HashSet<String>> = HashMap::new();
1663 for (module, table, col, _) in fills {
1664 fill_index
1665 .entry((module.clone(), table.clone()))
1666 .or_default()
1667 .insert(col.clone());
1668 }
1669
1670 let mut steps = diff_inner(target, &modified, true, &fill_index)?;
1671
1672 for (module, table, col, fill_expr) in fills {
1673 let fill_ops = vec![
1674 DiffOp {
1675 sql: format!(
1676 "UPDATE {} SET {} = {} WHERE {} IS NULL;",
1677 qn(module, table),
1678 qi(col),
1679 fill_expr,
1680 qi(col)
1681 ),
1682 non_transactional: false,
1683 },
1684 DiffOp {
1685 sql: format!(
1686 "ALTER TABLE {} ALTER COLUMN {} SET NOT NULL;",
1687 qn(module, table),
1688 qi(col)
1689 ),
1690 non_transactional: false,
1691 },
1692 ];
1693 match steps
1694 .iter_mut()
1695 .find(|s| matches!(&s.op_key, OpKey::Table(m, t) if m == module && t == table))
1696 {
1697 Some(step) => step.ddl.extend(fill_ops),
1698 None => steps.push(MigrationStep {
1699 prompt: format!("did you {} {}?", Verb::Alter.as_str(), verbosename_type(module, table)),
1700 verb: Verb::Alter,
1701 object_desc: verbosename_type(module, table),
1702 ddl: fill_ops,
1703 required_input: vec![],
1704 op_key: OpKey::Table(module.clone(), table.clone()),
1705 }),
1706 }
1707 }
1708
1709 Ok(steps)
1710}
1711
1712fn qi(s: &str) -> String {
1715 format!("\"{}\"", s.replace('"', "\"\""))
1716}
1717
1718fn pg_schema(module: &str) -> String {
1719 if module == "default" {
1720 "\"public\"".into()
1721 } else {
1722 qi(module)
1723 }
1724}
1725
1726fn enum_target(schema: &SchemaDescriptor, pg_type: &str) -> bool {
1730 let bare = pg_type.strip_suffix("[]").unwrap_or(pg_type);
1731 schema
1732 .enums
1733 .iter()
1734 .any(|e| bare == qn(&e.module, &e.name) || bare == qi(&e.name))
1735}
1736
1737fn qn(schema: &str, name: &str) -> String {
1738 format!("{}.{}", pg_schema(schema), qi(name))
1739}
1740
1741fn topo_sort_types(types: &[TypeDescriptor], polymorphic: &HashSet<String>) -> Vec<usize> {
1757 let idx_of: HashMap<String, usize> = types
1758 .iter()
1759 .enumerate()
1760 .map(|(i, t)| (format!("{}::{}", t.module, t.name), i))
1761 .collect();
1762
1763 let mut colour = vec![0u8; types.len()];
1765 let mut order: Vec<usize> = Vec::with_capacity(types.len());
1766
1767 fn visit(
1768 i: usize,
1769 types: &[TypeDescriptor],
1770 idx_of: &HashMap<String, usize>,
1771 polymorphic: &HashSet<String>,
1772 colour: &mut Vec<u8>,
1773 order: &mut Vec<usize>,
1774 ) {
1775 if colour[i] != 0 {
1776 return; }
1778 colour[i] = 1;
1779 for l in &types[i].links {
1783 if l.is_junction_backed() || polymorphic.contains(&l.target) {
1784 continue;
1785 }
1786 if let Some(&dep) = idx_of.get(&l.target) {
1787 visit(dep, types, idx_of, polymorphic, colour, order);
1788 }
1789 }
1790 colour[i] = 2;
1791 order.push(i);
1792 }
1793
1794 for i in 0..types.len() {
1795 visit(i, types, &idx_of, polymorphic, &mut colour, &mut order);
1796 }
1797 order
1798}
1799
1800fn col_type_str(p: &crate::schema::PropertyDescriptor) -> &str {
1804 p.column_type
1805 .as_deref()
1806 .unwrap_or_else(|| p.pg_type.strip_prefix("__nt__:").map(|_| "jsonb").unwrap_or(&p.pg_type))
1807}
1808
1809fn canonical_pg_type(pg_type: &str) -> String {
1815 if let Some(elem) = pg_type.strip_suffix("[]") {
1816 return format!("{}[]", canonical_pg_type(elem));
1817 }
1818 match pg_type {
1819 "int2" => "smallint",
1820 "int4" => "integer",
1821 "int8" => "bigint",
1822 "float4" => "real",
1823 "float8" => "double precision",
1824 "timestamptz" => "timestamp with time zone",
1825 "timestamp" => "timestamp without time zone",
1826 "time" => "time without time zone",
1827 other => other,
1828 }
1829 .to_string()
1830}
1831
1832fn bare_type_name(pg_type: &str) -> &str {
1842 pg_type.rsplit('.').next().unwrap_or(pg_type).trim_matches('"')
1843}
1844
1845fn pg_type_changed(target: &str, current: &str) -> bool {
1850 if target.starts_with('"') {
1851 bare_type_name(target) != bare_type_name(current)
1852 } else {
1853 canonical_pg_type(target) != canonical_pg_type(current)
1854 }
1855}
1856
1857fn diff_inner(
1860 target: &SchemaDescriptor,
1861 current: &DbState,
1862 for_migration: bool,
1863 fill_index: &HashMap<(String, String), HashSet<String>>,
1864) -> Result<Vec<MigrationStep>, String> {
1865 let mut steps = StepBuilder::new();
1866
1867 let cur_schemas: HashSet<&str> = current.schemas.iter().map(|s| s.as_str()).collect();
1868 let cur_tables: HashMap<(&str, &str), &DbTable> = current
1869 .tables
1870 .iter()
1871 .map(|t| ((t.schema.as_str(), t.name.as_str()), t))
1872 .collect();
1873 let cur_enums: HashMap<(&str, &str), &DbEnum> = current
1874 .enums
1875 .iter()
1876 .map(|e| ((e.schema.as_str(), e.name.as_str()), e))
1877 .collect();
1878 let cur_domains: HashSet<(&str, &str)> = current
1879 .domains
1880 .iter()
1881 .map(|d| (d.schema.as_str(), d.name.as_str()))
1882 .collect();
1883 let cur_sequences: HashSet<(&str, &str)> = current
1884 .sequences
1885 .iter()
1886 .map(|s| (s.schema.as_str(), s.name.as_str()))
1887 .collect();
1888 let cur_views: HashMap<(&str, &str), &str> = current
1889 .views
1890 .iter()
1891 .map(|v| ((v.schema.as_str(), v.name.as_str()), v.body_hash.as_str()))
1892 .collect();
1893 let cur_functions: HashMap<(&str, &str), &str> = current
1894 .functions
1895 .iter()
1896 .map(|f| ((f.schema.as_str(), f.name.as_str()), f.body_hash.as_str()))
1897 .collect();
1898
1899 let type_map: HashMap<String, (&str, &str)> = target
1900 .types
1901 .iter()
1902 .map(|t| {
1903 (
1904 format!("{}::{}", t.module, t.name),
1905 (t.module.as_str(), t.table.as_str()),
1906 )
1907 })
1908 .collect();
1909 let polymorphic = crate::export::polymorphic_types(target);
1910
1911 let mut target_schemas: HashSet<String> = HashSet::new();
1917 for t in &target.types {
1918 target_schemas.insert(t.module.clone());
1919 }
1920 for e in &target.enums {
1921 target_schemas.insert(e.module.clone());
1922 }
1923 for s in &target.scalars {
1924 target_schemas.insert(s.module.clone());
1925 }
1926 for f in &target.functions {
1927 target_schemas.insert(f.module.clone());
1928 }
1929 for g in &target.globals {
1930 target_schemas.insert(g.module.clone());
1931 }
1932 for a in &target.aliases {
1933 target_schemas.insert(a.module.clone());
1934 }
1935
1936 for module in &target_schemas {
1938 if module == "default" {
1939 continue;
1940 } if !cur_schemas.contains(module.as_str()) {
1942 steps.push(
1943 OpKey::Module(module.clone()),
1944 Verb::Create,
1945 verbosename_module(module),
1946 DiffOp {
1947 sql: format!("CREATE SCHEMA IF NOT EXISTS {};", pg_schema(module)),
1948 non_transactional: false,
1949 },
1950 );
1951 }
1952 }
1953
1954 for e in &target.enums {
1957 match cur_enums.get(&(e.module.as_str(), e.name.as_str())) {
1958 None => {
1959 let members: Vec<String> = e
1960 .members
1961 .iter()
1962 .map(|m| format!("'{}'", m.replace('\'', "''")))
1963 .collect();
1964 steps.push(
1965 OpKey::Scalar(e.module.clone(), e.name.clone()),
1966 Verb::Create,
1967 verbosename_scalar(&e.module, &e.name),
1968 DiffOp {
1969 sql: format!(
1970 "DO $$ BEGIN CREATE TYPE {}.{} AS ENUM ({}); \
1971 EXCEPTION WHEN duplicate_object THEN NULL; END $$;",
1972 pg_schema(&e.module),
1973 qi(&e.name),
1974 members.join(", ")
1975 ),
1976 non_transactional: false,
1977 },
1978 );
1979 }
1980 Some(existing) => {
1981 let existing_set: HashSet<&str> = existing.members.iter().map(|m| m.as_str()).collect();
1982 for member in &e.members {
1983 if !existing_set.contains(member.as_str()) {
1984 steps.push(
1985 OpKey::Scalar(e.module.clone(), e.name.clone()),
1986 Verb::Alter,
1987 verbosename_scalar(&e.module, &e.name),
1988 DiffOp {
1989 sql: format!(
1990 "ALTER TYPE {}.{} ADD VALUE IF NOT EXISTS '{}';",
1991 pg_schema(&e.module),
1992 qi(&e.name),
1993 member.replace('\'', "''")
1994 ),
1995 non_transactional: false,
1996 },
1997 );
1998 }
1999 }
2000 }
2001 }
2002 }
2003
2004 for s in &target.scalars {
2007 if s.is_sequence {
2008 let seq_name = format!("{}_seq", s.name);
2009 if !cur_sequences.contains(&(s.module.as_str(), seq_name.as_str())) {
2010 let verb = if cur_domains.contains(&(s.module.as_str(), s.name.as_str())) {
2011 Verb::Alter
2012 } else {
2013 Verb::Create
2014 };
2015 steps.push(
2016 OpKey::Scalar(s.module.clone(), s.name.clone()),
2017 verb,
2018 verbosename_scalar(&s.module, &s.name),
2019 DiffOp {
2020 sql: format!(
2021 "CREATE SEQUENCE IF NOT EXISTS {}.{};",
2022 pg_schema(&s.module),
2023 qi(&seq_name)
2024 ),
2025 non_transactional: false,
2026 },
2027 );
2028 }
2029 }
2030 }
2031
2032 for s in &target.scalars {
2034 if !cur_domains.contains(&(s.module.as_str(), s.name.as_str())) {
2035 let check_clause = crate::export::scalar_check_clauses(target, &s.module, &s.name);
2036 steps.push(
2037 OpKey::Scalar(s.module.clone(), s.name.clone()),
2038 Verb::Create,
2039 verbosename_scalar(&s.module, &s.name),
2040 DiffOp {
2041 sql: format!(
2042 "DO $do$ BEGIN CREATE DOMAIN {}.{} AS {}{}; \
2043 EXCEPTION WHEN duplicate_object THEN NULL; END $do$;",
2044 pg_schema(&s.module),
2045 qi(&s.name),
2046 s.pg_type,
2047 check_clause
2048 ),
2049 non_transactional: false,
2050 },
2051 );
2052 }
2053 }
2054
2055 let expected_domain_checks = crate::export::scalar_check_constraints(target);
2061 for cur_domain in ¤t.domains {
2062 if !target
2063 .scalars
2064 .iter()
2065 .any(|s| s.module == cur_domain.schema && s.name == cur_domain.name)
2066 {
2067 continue; }
2069 let wanted: Vec<&(String, String, String, String)> = expected_domain_checks
2070 .iter()
2071 .filter(|(module, name, _, _)| *module == cur_domain.schema && *name == cur_domain.name)
2072 .collect();
2073 let domain = format!("{}.{}", pg_schema(&cur_domain.schema), qi(&cur_domain.name));
2074 let unchanged = wanted.len() == cur_domain.checks.len()
2075 && wanted
2076 .iter()
2077 .all(|(_, _, cname, _)| cur_domain.checks.iter().any(|name| name == cname));
2078 if unchanged {
2079 continue;
2080 }
2081 let mut local: Vec<DiffOp> = Vec::new();
2082 push_tx(
2087 &mut local,
2088 format!(
2089 "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$;"
2090 ),
2091 );
2092 for (_, _, cname, expr) in wanted {
2093 push_tx(
2094 &mut local,
2095 format!("ALTER DOMAIN {} ADD CONSTRAINT {} CHECK ({});", domain, qi(cname), expr),
2096 );
2097 }
2098 if !local.is_empty() {
2099 steps.extend(
2100 OpKey::Scalar(cur_domain.schema.clone(), cur_domain.name.clone()),
2101 Verb::Alter,
2102 verbosename_scalar(&cur_domain.schema, &cur_domain.name),
2103 local,
2104 );
2105 }
2106 }
2107
2108 let scalar_fn_ddls = crate::export::scalar_function_ddl_with_names(target).map_err(|e| e.to_string())?;
2110 for (module, name, ddl) in scalar_fn_ddls {
2111 let emit = if for_migration {
2112 let hash = ddl_hash(&ddl);
2113 cur_functions
2114 .get(&(module.as_str(), name.as_str()))
2115 .map(|&h| h != hash)
2116 .unwrap_or(true)
2117 } else {
2118 true
2119 };
2120 if emit {
2121 let verb = if cur_functions.contains_key(&(module.as_str(), name.as_str())) {
2122 Verb::Alter
2123 } else {
2124 Verb::Create
2125 };
2126 steps.push(
2127 OpKey::Function(module.clone(), name.clone()),
2128 verb,
2129 verbosename_function(&module, &name),
2130 DiffOp {
2131 sql: ddl,
2132 non_transactional: false,
2133 },
2134 );
2135 }
2136 }
2137
2138 let sort_order = topo_sort_types(&target.types, &polymorphic);
2140
2141 let mut new_tables: HashSet<(String, String)> = HashSet::new();
2143
2144 for &i in &sort_order {
2145 let td = &target.types[i];
2146 if td.abstract_ || td.junction {
2147 continue;
2148 }
2149 let key = (td.module.as_str(), td.table.as_str());
2150 match cur_tables.get(&key) {
2151 None => {
2152 let mut local: Vec<DiffOp> = Vec::new();
2153 emit_create_table(td, target, &mut local);
2154 steps.extend(
2155 OpKey::Table(td.module.clone(), td.table.clone()),
2156 Verb::Create,
2157 verbosename_type(&td.module, &td.name),
2158 local,
2159 );
2160 new_tables.insert((td.module.clone(), td.table.clone()));
2161 }
2162 Some(existing) => {
2163 let fill_cols = fill_index
2164 .get(&(td.module.clone(), td.table.clone()))
2165 .cloned()
2166 .unwrap_or_default();
2167 let mut local: Vec<DiffOp> = Vec::new();
2168 let mut inputs: Vec<RequiredInput> = Vec::new();
2169 emit_column_diff(td, existing, &mut local, for_migration, &fill_cols, target, &mut inputs);
2170 steps.extend_with_input(
2171 OpKey::Table(td.module.clone(), td.table.clone()),
2172 Verb::Alter,
2173 verbosename_type(&td.module, &td.name),
2174 local,
2175 inputs,
2176 );
2177 }
2178 }
2179 }
2180
2181 for &i in &sort_order {
2188 let td = &target.types[i];
2189 if td.abstract_ || td.junction {
2190 continue;
2191 }
2192 let existing = cur_tables.get(&(td.module.as_str(), td.table.as_str())).copied();
2193 let verb = if new_tables.contains(&(td.module.clone(), td.table.clone())) {
2194 Verb::Create
2195 } else {
2196 Verb::Alter
2197 };
2198 let mut local: Vec<DiffOp> = Vec::new();
2199 emit_fk_diff(td, existing, &type_map, &polymorphic, &mut local);
2200 steps.extend(
2201 OpKey::ForeignKey(td.module.clone(), td.table.clone()),
2202 verb,
2203 verbosename_type(&td.module, &td.name),
2204 local,
2205 );
2206 }
2207
2208 for (jt_module, jt_name, cname, ddl) in crate::export::junction_fk_constraints(target, &type_map) {
2213 let already_there = cur_tables
2214 .get(&(jt_module.as_str(), jt_name.as_str()))
2215 .map(|t| t.foreign_keys.iter().any(|fk| fk.constraint_name == cname))
2216 .unwrap_or(false);
2217 if already_there {
2218 continue;
2219 }
2220 let verb = if cur_tables.contains_key(&(jt_module.as_str(), jt_name.as_str())) {
2221 Verb::Alter
2222 } else {
2223 Verb::Create
2224 };
2225 let mut local: Vec<DiffOp> = Vec::new();
2226 push_tx(&mut local, ddl);
2227 steps.extend(
2228 OpKey::ForeignKey(jt_module.clone(), jt_name.clone()),
2229 verb,
2230 format!("link table '{}.{}'", jt_module, jt_name),
2231 local,
2232 );
2233 }
2234
2235 for (module, table, cname, ddl) in crate::export::check_constraints(target).map_err(|e| e.to_string())? {
2242 let already_there = cur_tables
2243 .get(&(module.as_str(), table.as_str()))
2244 .map(|t| t.checks.iter().any(|c| c.constraint_name == cname))
2245 .unwrap_or(false);
2246 if already_there {
2247 continue;
2248 }
2249 let verb = if cur_tables.contains_key(&(module.as_str(), table.as_str())) {
2250 Verb::Alter
2251 } else {
2252 Verb::Create
2253 };
2254 let mut local: Vec<DiffOp> = Vec::new();
2255 push_tx(&mut local, ddl);
2256 steps.extend(
2257 OpKey::ForeignKey(module.clone(), format!("{table}#checks")),
2258 verb,
2259 verbosename_type(&module, &table),
2260 local,
2261 );
2262 }
2263
2264 for &i in &sort_order {
2270 let td = &target.types[i];
2271 if td.abstract_ || td.junction {
2272 continue;
2273 }
2274 let owner_key = OpKey::Table(td.module.clone(), td.table.clone());
2275 let owner_verb = if new_tables.contains(&(td.module.clone(), td.table.clone())) {
2276 Verb::Create
2277 } else {
2278 Verb::Alter
2279 };
2280 let owner_desc = verbosename_type(&td.module, &td.name);
2281 for ml in &td.multilinks {
2282 let jt = format!("{}.{}", td.table, ml.name);
2283 if !cur_tables.contains_key(&(td.module.as_str(), jt.as_str())) {
2284 let mut local: Vec<DiffOp> = Vec::new();
2285 emit_junction_table(td, &ml.name, ml.through.as_deref(), target, false, &mut local);
2286 steps.extend(owner_key.clone(), owner_verb, owner_desc.clone(), local);
2287 new_tables.insert((td.module.clone(), jt));
2288 }
2289 }
2290 for l in &td.links {
2291 if !l.is_junction_backed() {
2292 continue;
2293 }
2294 let jt = format!("{}.{}", td.table, l.name);
2295 if !cur_tables.contains_key(&(td.module.as_str(), jt.as_str())) {
2296 let mut local: Vec<DiffOp> = Vec::new();
2297 emit_junction_table(td, &l.name, l.through.as_deref(), target, true, &mut local);
2298 steps.extend(owner_key.clone(), owner_verb, owner_desc.clone(), local);
2299 new_tables.insert((td.module.clone(), jt));
2300 }
2301 }
2302 }
2303
2304 for &i in &sort_order {
2306 let td = &target.types[i];
2307 if td.abstract_ || td.vector_indexes.is_empty() {
2308 continue;
2309 }
2310 let existing = cur_tables.get(&(td.module.as_str(), td.table.as_str()));
2311 let table_is_new = new_tables.contains(&(td.module.clone(), td.table.clone()));
2312 let owner_key = OpKey::Table(td.module.clone(), td.table.clone());
2313 let owner_verb = if table_is_new { Verb::Create } else { Verb::Alter };
2314 let owner_desc = verbosename_type(&td.module, &td.name);
2315
2316 for vi in &td.vector_indexes {
2317 let col = vi.column_name();
2318 if existing
2319 .map(|t| t.columns.iter().any(|c| c.name == col))
2320 .unwrap_or(false)
2321 {
2322 continue;
2323 }
2324 let mut local: Vec<DiffOp> = Vec::new();
2325 push_tx(
2326 &mut local,
2327 format!(
2328 "ALTER TABLE {} ADD COLUMN IF NOT EXISTS {} vector({});",
2329 qn(&td.module, &td.table),
2330 qi(&col),
2331 vi.dimensions
2332 ),
2333 );
2334 let idx_name = match &vi.index_name {
2335 None => format!("{}__vector__", td.table),
2336 Some(n) => format!("{}__vector_{}__", td.table, n),
2337 };
2338 let use_concurrently = for_migration && !table_is_new;
2341 let idx_sql = if use_concurrently {
2342 format!(
2343 "CREATE INDEX CONCURRENTLY IF NOT EXISTS {} ON {} USING hnsw ({} {});",
2344 qi(&idx_name),
2345 qn(&td.module, &td.table),
2346 qi(&col),
2347 vi.ops_class()
2348 )
2349 } else {
2350 format!(
2351 "CREATE INDEX IF NOT EXISTS {} ON {} USING hnsw ({} {});",
2352 qi(&idx_name),
2353 qn(&td.module, &td.table),
2354 qi(&col),
2355 vi.ops_class()
2356 )
2357 };
2358 local.push(DiffOp {
2359 sql: idx_sql,
2360 non_transactional: use_concurrently,
2361 });
2362 steps.extend(owner_key.clone(), owner_verb, owner_desc.clone(), local);
2363 }
2364 }
2365
2366 for &i in &sort_order {
2368 let td = &target.types[i];
2369 if td.abstract_ || td.search_indexes.is_empty() {
2370 continue;
2371 }
2372 let existing = cur_tables.get(&(td.module.as_str(), td.table.as_str()));
2373 let table_is_new = new_tables.contains(&(td.module.clone(), td.table.clone()));
2374 let owner_key = OpKey::Table(td.module.clone(), td.table.clone());
2375 let owner_verb = if table_is_new { Verb::Create } else { Verb::Alter };
2376 let owner_desc = verbosename_type(&td.module, &td.name);
2377
2378 for si in &td.search_indexes {
2379 if si.backend != SearchBackend::Postgres {
2380 continue;
2381 }
2382 let col = si.column_name();
2383 if existing
2384 .map(|t| t.columns.iter().any(|c| c.name == col))
2385 .unwrap_or(false)
2386 {
2387 continue;
2388 }
2389 let mut local: Vec<DiffOp> = Vec::new();
2390 let parts: Vec<String> = si
2391 .pointers
2392 .iter()
2393 .map(|sf| {
2394 format!(
2395 "setweight(to_tsvector('english', coalesce({}, '')), '{}')",
2396 qi(&sf.name),
2397 sf.weight.as_str()
2398 )
2399 })
2400 .collect();
2401 let expr = if parts.len() == 1 {
2402 parts.into_iter().next().unwrap()
2403 } else {
2404 parts.join(" || ")
2405 };
2406 push_tx(
2407 &mut local,
2408 format!(
2409 "ALTER TABLE {} ADD COLUMN IF NOT EXISTS {} tsvector GENERATED ALWAYS AS ({}) STORED;",
2410 qn(&td.module, &td.table),
2411 qi(&col),
2412 expr
2413 ),
2414 );
2415 let idx_name = match &si.index_name {
2416 None => format!("{}__search__", td.table),
2417 Some(n) => format!("{}__search_{}__", td.table, n),
2418 };
2419 let use_concurrently = for_migration && !table_is_new;
2420 let idx_sql = if use_concurrently {
2421 format!(
2422 "CREATE INDEX CONCURRENTLY IF NOT EXISTS {} ON {} USING gin ({});",
2423 qi(&idx_name),
2424 qn(&td.module, &td.table),
2425 qi(&col)
2426 )
2427 } else {
2428 format!(
2429 "CREATE INDEX IF NOT EXISTS {} ON {} USING gin ({});",
2430 qi(&idx_name),
2431 qn(&td.module, &td.table),
2432 qi(&col)
2433 )
2434 };
2435 local.push(DiffOp {
2436 sql: idx_sql,
2437 non_transactional: use_concurrently,
2438 });
2439 steps.extend(owner_key.clone(), owner_verb, owner_desc.clone(), local);
2440 }
2441 }
2442
2443 let expected_state = schema_to_db_state(target);
2449 let current_indexes: HashMap<(&str, &str), &[DbIndex]> = current
2450 .tables
2451 .iter()
2452 .map(|t| ((t.schema.as_str(), t.name.as_str()), t.indexes.as_slice()))
2453 .collect();
2454 for table in &expected_state.tables {
2455 let existing = current_indexes
2456 .get(&(table.schema.as_str(), table.name.as_str()))
2457 .copied()
2458 .unwrap_or_default();
2459 let table_is_new = !current_indexes.contains_key(&(table.schema.as_str(), table.name.as_str()));
2460 for idx in &table.indexes {
2461 if idx.columns.is_empty() && idx.key.is_none() {
2464 continue;
2465 }
2466 let already_there = match &idx.key {
2472 Some(_) => existing.iter().any(|b| b.name == idx.name),
2473 None => existing.iter().any(|b| {
2474 b.columns == idx.columns
2475 && b.is_unique == idx.is_unique
2476 && b.predicate.is_some() == idx.predicate.is_some()
2477 }),
2478 };
2479 if already_there {
2480 continue;
2481 }
2482 let use_concurrently = for_migration && !table_is_new;
2483 let concurrently = if use_concurrently { "CONCURRENTLY " } else { "" };
2484 let unique = if idx.is_unique { "UNIQUE " } else { "" };
2485 let body = match &idx.key {
2486 Some(key) => key.clone(),
2487 None => format!("({})", idx.columns.iter().map(|c| qi(c)).collect::<Vec<_>>().join(", ")),
2488 };
2489 let sql = format!(
2490 "CREATE {unique}INDEX {concurrently}IF NOT EXISTS {} ON {} {}{};",
2491 qi(&capped_index_name(idx.name.clone())),
2492 qn(&table.schema, &table.name),
2493 body,
2494 idx.predicate.clone().unwrap_or_default(),
2495 );
2496 let owner_key = OpKey::Table(table.schema.clone(), table.name.clone());
2497 let owner_verb = if table_is_new { Verb::Create } else { Verb::Alter };
2498 let owner_desc = verbosename_type(&table.schema, &table.name);
2499 steps.extend(
2500 owner_key,
2501 owner_verb,
2502 owner_desc,
2503 vec![DiffOp {
2504 sql,
2505 non_transactional: use_concurrently,
2506 }],
2507 );
2508 }
2509 }
2510
2511 for (module, name, ddl) in crate::export::interface_view_ddl_with_names(target) {
2515 let emit = if for_migration {
2516 let hash = ddl_hash(&ddl);
2517 cur_views
2518 .get(&(module.as_str(), name.as_str()))
2519 .map(|&h| h != hash)
2520 .unwrap_or(true)
2521 } else {
2522 true
2523 };
2524 if emit {
2525 let verb = if cur_views.contains_key(&(module.as_str(), name.as_str())) {
2526 Verb::Alter
2527 } else {
2528 Verb::Create
2529 };
2530 steps.push(
2531 OpKey::View(module.clone(), name.clone()),
2532 verb,
2533 verbosename_interface(&module, &name),
2534 DiffOp {
2535 sql: ddl,
2536 non_transactional: false,
2537 },
2538 );
2539 }
2540 }
2541
2542 for (module, name, ddl) in crate::export::interface_junction_view_ddl_with_names(target) {
2550 let emit = if for_migration {
2551 let hash = ddl_hash(&ddl);
2552 cur_views
2553 .get(&(module.as_str(), name.as_str()))
2554 .map(|&h| h != hash)
2555 .unwrap_or(true)
2556 } else {
2557 true
2558 };
2559 if emit {
2560 let verb = if cur_views.contains_key(&(module.as_str(), name.as_str())) {
2561 Verb::Alter
2562 } else {
2563 Verb::Create
2564 };
2565 steps.push(
2566 OpKey::View(module.clone(), name.clone()),
2567 verb,
2568 verbosename_interface(&module, &name),
2569 DiffOp {
2570 sql: ddl,
2571 non_transactional: false,
2572 },
2573 );
2574 }
2575 }
2576
2577 let target_views: HashSet<(String, String)> = crate::export::interface_view_ddl_with_names(target)
2582 .into_iter()
2583 .chain(crate::export::interface_junction_view_ddl_with_names(target))
2584 .map(|(module, name, _)| (module, name))
2585 .collect();
2586 for view in ¤t.views {
2587 if !target_views.contains(&(view.schema.clone(), view.name.clone())) {
2588 steps.push(
2589 OpKey::View(view.schema.clone(), view.name.clone()),
2590 Verb::Drop,
2591 verbosename_interface(&view.schema, &view.name),
2592 DiffOp {
2593 sql: format!("DROP VIEW IF EXISTS {};", qn(&view.schema, &view.name)),
2594 non_transactional: false,
2595 },
2596 );
2597 }
2598 }
2599
2600 let obj_fn_ddls = crate::export::object_function_ddl_with_names(target).map_err(|e| e.to_string())?;
2602 for (module, name, ddl) in obj_fn_ddls {
2603 let emit = if for_migration {
2604 let hash = ddl_hash(&ddl);
2605 cur_functions
2606 .get(&(module.as_str(), name.as_str()))
2607 .map(|&h| h != hash)
2608 .unwrap_or(true)
2609 } else {
2610 true
2611 };
2612 if emit {
2613 let verb = if cur_functions.contains_key(&(module.as_str(), name.as_str())) {
2614 Verb::Alter
2615 } else {
2616 Verb::Create
2617 };
2618 steps.push(
2619 OpKey::Function(module.clone(), name.clone()),
2620 verb,
2621 verbosename_function(&module, &name),
2622 DiffOp {
2623 sql: ddl,
2624 non_transactional: false,
2625 },
2626 );
2627 }
2628 }
2629
2630 let mut target_tables: HashSet<(String, String)> = HashSet::new();
2635 for td in &target.types {
2636 if !td.abstract_ {
2637 target_tables.insert((td.module.clone(), td.table.clone()));
2638 }
2639 if !td.abstract_ && !td.junction {
2640 for ml in &td.multilinks {
2641 target_tables.insert((td.module.clone(), format!("{}.{}", td.table, ml.name)));
2642 }
2643 for l in &td.links {
2644 if !l.is_junction_backed() {
2645 continue;
2646 }
2647 target_tables.insert((td.module.clone(), format!("{}.{}", td.table, l.name)));
2648 }
2649 }
2650 }
2651
2652 {
2656 let infos = crate::export::interface_exclusive_trigger_infos(target);
2657 let cur_trigger_map: HashMap<(&str, &str), HashSet<&str>> = current
2658 .tables
2659 .iter()
2660 .map(|t| {
2661 (
2662 (t.schema.as_str(), t.name.as_str()),
2663 t.triggers.iter().map(|n| n.as_str()).collect::<HashSet<_>>(),
2664 )
2665 })
2666 .collect();
2667
2668 let expected_trigger_map = expected_triggers(target, &type_map);
2674 let mut fn_emitted: HashSet<String> = HashSet::new();
2676
2677 let owner_of = |module: &str, table: &str| -> (OpKey, Verb, String) {
2682 for &i in &sort_order {
2683 let td = &target.types[i];
2684 if td.abstract_ || td.junction {
2685 continue;
2686 }
2687 let is_owner = td.module == module
2688 && (td.table == table
2689 || td
2690 .multilinks
2691 .iter()
2692 .any(|ml| format!("{}.{}", td.table, ml.name) == table)
2693 || td
2694 .links
2695 .iter()
2696 .any(|l| l.is_junction_backed() && format!("{}.{}", td.table, l.name) == table));
2697 if is_owner {
2698 let verb = if new_tables.contains(&(td.module.clone(), td.table.clone())) {
2699 Verb::Create
2700 } else {
2701 Verb::Alter
2702 };
2703 return (
2704 OpKey::Table(td.module.clone(), td.table.clone()),
2705 verb,
2706 verbosename_type(&td.module, &td.name),
2707 );
2708 }
2709 }
2710 (
2711 OpKey::Table(module.to_string(), table.to_string()),
2712 Verb::Alter,
2713 verbosename_type(module, table),
2714 )
2715 };
2716
2717 for info in &infos {
2718 let cur = cur_trigger_map
2719 .get(&(info.impl_module.as_str(), info.impl_table.as_str()))
2720 .cloned()
2721 .unwrap_or_default();
2722 let need_ins = !cur.contains(info.ins_trigger_name.as_str());
2723 let need_upd = !cur.contains(info.upd_trigger_name.as_str());
2724 if need_ins || need_upd {
2725 let mut local: Vec<DiffOp> = Vec::new();
2726 if fn_emitted.insert(info.fn_name.clone()) {
2727 push_tx(&mut local, info.fn_ddl.clone());
2728 }
2729 if need_ins {
2730 push_tx(&mut local, info.ins_ddl.clone());
2731 }
2732 if need_upd {
2733 push_tx(&mut local, info.upd_ddl.clone());
2734 }
2735 let (key, verb, desc) = owner_of(&info.impl_module, &info.impl_table);
2736 steps.extend(key, verb, desc, local);
2737 }
2738 }
2739
2740 for info in crate::export::deletion_policy_trigger_infos(target, &type_map) {
2748 let cur = cur_trigger_map
2749 .get(&(info.table_module.as_str(), info.table_name.as_str()))
2750 .cloned()
2751 .unwrap_or_default();
2752 if !cur.contains(info.trigger_name.as_str()) {
2753 let (key, verb, desc) = owner_of(&info.table_module, &info.table_name);
2754 steps.extend(
2755 key,
2756 verb,
2757 desc,
2758 vec![DiffOp {
2759 sql: info.ddl.clone(),
2760 non_transactional: false,
2761 }],
2762 );
2763 }
2764 }
2765
2766 for info in crate::export::signal_trigger_infos(target) {
2773 let cur = cur_trigger_map
2774 .get(&(info.table_module.as_str(), info.table_name.as_str()))
2775 .cloned()
2776 .unwrap_or_default();
2777 if !cur.contains(info.trigger_name.as_str()) {
2778 let (key, verb, desc) = owner_of(&info.table_module, &info.table_name);
2779 steps.extend(
2780 key,
2781 verb,
2782 desc,
2783 vec![DiffOp {
2784 sql: info.ddl.clone(),
2785 non_transactional: false,
2786 }],
2787 );
2788 }
2789 }
2790
2791 for info in crate::export::user_trigger_infos(target).map_err(|e| e.to_string())? {
2802 let cur = cur_trigger_map
2803 .get(&(info.table_module.as_str(), info.table_name.as_str()))
2804 .cloned()
2805 .unwrap_or_default();
2806 if !cur.contains(info.trigger_name.as_str()) {
2807 let (key, verb, desc) = owner_of(&info.table_module, &info.table_name);
2808 steps.extend(
2809 key,
2810 verb,
2811 desc,
2812 vec![DiffOp {
2813 sql: info.ddl.clone(),
2814 non_transactional: false,
2815 }],
2816 );
2817 }
2818 }
2819
2820 let mut cache_trigger_tables: HashSet<(String, String)> = HashSet::new();
2834 for td in &target.types {
2835 if td.abstract_ || junction_table_belongs_to_an_interface(target, td) {
2836 continue;
2837 }
2838 cache_trigger_tables.insert((td.module.clone(), td.table.clone()));
2839 if !td.junction {
2840 for ml in &td.multilinks {
2841 cache_trigger_tables.insert((td.module.clone(), format!("{}.{}", td.table, ml.name)));
2842 }
2843 for l in &td.links {
2844 if !l.is_junction_backed() {
2845 continue;
2846 }
2847 cache_trigger_tables.insert((td.module.clone(), format!("{}.{}", td.table, l.name)));
2848 }
2849 }
2850 }
2851 for (module, table) in &cache_trigger_tables {
2852 let already_present = cur_trigger_map
2853 .get(&(module.as_str(), table.as_str()))
2854 .map(|t| t.contains("pylon_cache_invalidate"))
2855 .unwrap_or(false);
2856 if !already_present {
2857 let (key, verb, desc) = owner_of(module, table);
2858 steps.extend(
2859 key,
2860 verb,
2861 desc,
2862 vec![DiffOp {
2863 sql: cache_invalidate_trigger_sql(&qn(module, table)),
2864 non_transactional: false,
2865 }],
2866 );
2867 }
2868 }
2869
2870 for cur_table in ¤t.tables {
2881 let key = (cur_table.schema.clone(), cur_table.name.clone());
2882 if !target_tables.contains(&key) {
2883 continue;
2884 }
2885 let expected = expected_trigger_map.get(&key).cloned().unwrap_or_default();
2886 for trigger_name in &cur_table.triggers {
2887 if !expected.contains(trigger_name) {
2888 let (owner_key, verb, desc) = owner_of(&cur_table.schema, &cur_table.name);
2889 steps.extend(
2890 owner_key,
2891 verb,
2892 desc,
2893 vec![
2894 DiffOp {
2895 sql: format!(
2896 "DROP TRIGGER IF EXISTS {} ON {};",
2897 qi(trigger_name),
2898 qn(&cur_table.schema, &cur_table.name)
2899 ),
2900 non_transactional: false,
2901 },
2902 DiffOp {
2920 sql: format!("DROP FUNCTION IF EXISTS {}();", qn(&cur_table.schema, trigger_name)),
2921 non_transactional: false,
2922 },
2923 ],
2924 );
2925 }
2926 }
2927 }
2928 }
2929
2930 let expected_checks: HashSet<(String, String, String)> = crate::export::check_constraints(target)
2937 .map_err(|e| e.to_string())?
2938 .into_iter()
2939 .map(|(module, table, name, _)| (module, table, name))
2940 .collect();
2941 for cur_table in ¤t.tables {
2942 if !target_tables.contains(&(cur_table.schema.clone(), cur_table.name.clone())) {
2943 continue; }
2945 for check in &cur_table.checks {
2946 if !is_generated_check_name(&check.constraint_name) {
2947 continue;
2948 }
2949 let key = (
2950 cur_table.schema.clone(),
2951 cur_table.name.clone(),
2952 check.constraint_name.clone(),
2953 );
2954 if expected_checks.contains(&key) {
2955 continue;
2956 }
2957 steps.extend(
2958 OpKey::ForeignKey(cur_table.schema.clone(), format!("{}#checks", cur_table.name)),
2959 Verb::Alter,
2960 verbosename_type(&cur_table.schema, &cur_table.name),
2961 vec![DiffOp {
2962 sql: format!(
2963 "ALTER TABLE {} DROP CONSTRAINT IF EXISTS {};",
2964 qn(&cur_table.schema, &cur_table.name),
2965 qi(&check.constraint_name)
2966 ),
2967 non_transactional: false,
2968 }],
2969 );
2970 }
2971 }
2972
2973 let polymorphic_targets = crate::export::polymorphic_types(target);
2976 let mut unwanted_fks: HashSet<(String, String, String)> = HashSet::new();
2977 for t in target.types.iter().filter(|t| !t.abstract_ && !t.junction) {
2978 for l in &t.links {
2979 if !polymorphic_targets.contains(&l.target) {
2980 continue;
2981 }
2982 unwanted_fks.insert(if l.is_junction_backed() {
2983 (
2984 t.module.clone(),
2985 format!("{}.{}", t.table, l.name),
2986 format!("{}_{}_target_fkey", t.table, l.name),
2987 )
2988 } else {
2989 (
2990 t.module.clone(),
2991 t.table.clone(),
2992 format!("{}_{}_fkey", t.table, l.name),
2993 )
2994 });
2995 }
2996 for ml in t
2997 .multilinks
2998 .iter()
2999 .filter(|ml| polymorphic_targets.contains(&ml.target))
3000 {
3001 unwanted_fks.insert((
3002 t.module.clone(),
3003 format!("{}.{}", t.table, ml.name),
3004 format!("{}_{}_target_fkey", t.table, ml.name),
3005 ));
3006 }
3007 }
3008 for cur_table in ¤t.tables {
3009 for fk in &cur_table.foreign_keys {
3010 let key = (
3011 cur_table.schema.clone(),
3012 cur_table.name.clone(),
3013 fk.constraint_name.clone(),
3014 );
3015 let references_a_table = target
3017 .types
3018 .iter()
3019 .any(|t| !t.abstract_ && t.module == fk.ref_schema && t.table == fk.ref_table);
3020 if !unwanted_fks.contains(&key) || !references_a_table {
3021 continue;
3022 }
3023 steps.extend(
3024 OpKey::ForeignKey(cur_table.schema.clone(), cur_table.name.clone()),
3025 Verb::Alter,
3026 verbosename_type(&cur_table.schema, &cur_table.name),
3027 vec![DiffOp {
3028 sql: format!(
3029 "ALTER TABLE {} DROP CONSTRAINT IF EXISTS {};",
3030 qn(&cur_table.schema, &cur_table.name),
3031 qi(&fk.constraint_name)
3032 ),
3033 non_transactional: false,
3034 }],
3035 );
3036 }
3037 }
3038
3039 for cur_table in ¤t.tables {
3041 let key = (cur_table.schema.clone(), cur_table.name.clone());
3042 if !target_tables.contains(&key) {
3043 steps.push(
3044 OpKey::Table(cur_table.schema.clone(), cur_table.name.clone()),
3045 Verb::Drop,
3046 verbosename_type(&cur_table.schema, &cur_table.name),
3047 DiffOp {
3048 sql: format!(
3049 "DROP TABLE IF EXISTS {} CASCADE;",
3050 qn(&cur_table.schema, &cur_table.name)
3051 ),
3052 non_transactional: false,
3053 },
3054 );
3055 }
3056 }
3057
3058 let target_enum_set: HashSet<(String, String)> = target
3060 .enums
3061 .iter()
3062 .map(|e| (e.module.clone(), e.name.clone()))
3063 .collect();
3064 for cur_enum in ¤t.enums {
3065 if !target_enum_set.contains(&(cur_enum.schema.clone(), cur_enum.name.clone())) {
3066 steps.push(
3067 OpKey::Scalar(cur_enum.schema.clone(), cur_enum.name.clone()),
3068 Verb::Drop,
3069 verbosename_scalar(&cur_enum.schema, &cur_enum.name),
3070 DiffOp {
3071 sql: format!(
3072 "DROP TYPE IF EXISTS {}.{} CASCADE;",
3073 pg_schema(&cur_enum.schema),
3074 qi(&cur_enum.name)
3075 ),
3076 non_transactional: false,
3077 },
3078 );
3079 }
3080 }
3081
3082 let target_domain_set: HashSet<(String, String)> = target
3084 .scalars
3085 .iter()
3086 .map(|s| (s.module.clone(), s.name.clone()))
3087 .collect();
3088 for cur_domain in ¤t.domains {
3089 if !target_domain_set.contains(&(cur_domain.schema.clone(), cur_domain.name.clone())) {
3090 steps.push(
3091 OpKey::Scalar(cur_domain.schema.clone(), cur_domain.name.clone()),
3092 Verb::Drop,
3093 verbosename_scalar(&cur_domain.schema, &cur_domain.name),
3094 DiffOp {
3095 sql: format!(
3096 "DROP DOMAIN IF EXISTS {}.{} CASCADE;",
3097 pg_schema(&cur_domain.schema),
3098 qi(&cur_domain.name)
3099 ),
3100 non_transactional: false,
3101 },
3102 );
3103 }
3104 }
3105
3106 let target_sequence_set: HashSet<(String, String)> = target
3109 .scalars
3110 .iter()
3111 .filter(|s| s.is_sequence)
3112 .map(|s| (s.module.clone(), format!("{}_seq", s.name)))
3113 .collect();
3114 for cur_seq in ¤t.sequences {
3115 if !target_sequence_set.contains(&(cur_seq.schema.clone(), cur_seq.name.clone())) {
3116 let scalar_name = cur_seq.name.strip_suffix("_seq").unwrap_or(&cur_seq.name).to_string();
3117 steps.push(
3118 OpKey::Scalar(cur_seq.schema.clone(), scalar_name.clone()),
3119 Verb::Drop,
3120 verbosename_scalar(&cur_seq.schema, &scalar_name),
3121 DiffOp {
3122 sql: format!(
3123 "DROP SEQUENCE IF EXISTS {}.{};",
3124 pg_schema(&cur_seq.schema),
3125 qi(&cur_seq.name)
3126 ),
3127 non_transactional: false,
3128 },
3129 );
3130 }
3131 }
3132
3133 for module in ¤t.schemas {
3135 if module == "default" {
3136 continue;
3137 } if !target_schemas.contains(module) {
3139 steps.push(
3140 OpKey::Module(module.clone()),
3141 Verb::Drop,
3142 verbosename_module(module),
3143 DiffOp {
3144 sql: format!("DROP SCHEMA IF EXISTS {} CASCADE;", pg_schema(module)),
3145 non_transactional: false,
3146 },
3147 );
3148 }
3149 }
3150
3151 Ok(steps.finish())
3152}
3153
3154fn push_tx(ops: &mut Vec<DiffOp>, sql: String) {
3155 ops.push(DiffOp {
3156 sql,
3157 non_transactional: false,
3158 });
3159}
3160
3161fn resolve_default(p: &crate::schema::PropertyDescriptor, schema: &SchemaDescriptor) -> Option<String> {
3167 if let Some(sql) = &p.default_sql {
3168 return Some(sql.clone());
3169 }
3170 if let Some(pyql) = &p.default_pyql {
3171 return crate::ir::column_default_sql(pyql, schema);
3172 }
3173 None
3174}
3175
3176#[cfg(test)]
3179pub(crate) fn resolve_default_for_test(
3180 p: &crate::schema::PropertyDescriptor,
3181 schema: &SchemaDescriptor,
3182) -> Option<String> {
3183 resolve_default(p, schema)
3184}
3185
3186fn resolve_link_default(l: &crate::schema::LinkDescriptor, schema: &SchemaDescriptor) -> Option<String> {
3187 if let Some(pyql) = &l.default_pyql {
3188 return crate::ir::column_default_sql(pyql, schema);
3189 }
3190 None
3191}
3192
3193fn emit_create_table(td: &TypeDescriptor, schema: &SchemaDescriptor, ops: &mut Vec<DiffOp>) {
3196 let mut lines: Vec<String> = Vec::new();
3197 for p in &td.properties {
3198 let not_null = if p.nullable { "" } else { " NOT NULL" };
3199 let default = resolve_default(p, schema)
3200 .map(|d| format!(" DEFAULT {}", d))
3201 .unwrap_or_default();
3202 lines.push(format!(
3203 " {} {}{}{}",
3204 qi(&p.name),
3205 col_type_str(p),
3206 not_null,
3207 default
3208 ));
3209 }
3210 for l in &td.links {
3211 if l.is_junction_backed() {
3212 continue;
3213 }
3214 let not_null = if l.nullable { "" } else { " NOT NULL" };
3215 let default = resolve_link_default(l, schema)
3216 .map(|d| format!(" DEFAULT {}", d))
3217 .unwrap_or_default();
3218 lines.push(format!(
3219 " {} uuid{}{}",
3220 qi(&format!("{}_id", l.name)),
3221 not_null,
3222 default
3223 ));
3224 }
3225 let pk_cols: Vec<String> = td.properties.iter().filter(|p| p.is_pk).map(|p| qi(&p.name)).collect();
3226 if !pk_cols.is_empty() {
3227 lines.push(format!(" PRIMARY KEY ({})", pk_cols.join(", ")));
3228 }
3229 push_tx(
3230 ops,
3231 format!(
3232 "CREATE TABLE IF NOT EXISTS {} (\n{}\n);",
3233 qn(&td.module, &td.table),
3234 lines.join(",\n")
3235 ),
3236 );
3237}
3238
3239fn cache_invalidate_trigger_sql(qualified_table: &str) -> String {
3244 format!(
3245 "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();",
3246 qualified_table
3247 )
3248}
3249
3250fn emit_column_diff(
3257 td: &TypeDescriptor,
3258 existing: &DbTable,
3259 ops: &mut Vec<DiffOp>,
3260 for_migration: bool,
3261 fill_cols: &HashSet<String>,
3262 schema: &SchemaDescriptor,
3263 required_input: &mut Vec<RequiredInput>,
3264) {
3265 let existing_col_map: HashMap<&str, &DbColumn> = existing.columns.iter().map(|c| (c.name.as_str(), c)).collect();
3266
3267 for p in &td.properties {
3269 if existing_col_map.contains_key(p.name.as_str()) {
3270 continue;
3271 }
3272 let eff_default = resolve_default(p, schema);
3273 let needs_fill = for_migration && !p.nullable && eff_default.is_none() && fill_cols.contains(&p.name);
3274 let not_null = if p.nullable || needs_fill { "" } else { " NOT NULL" };
3275 let default = eff_default.map(|d| format!(" DEFAULT {}", d)).unwrap_or_default();
3276 push_tx(
3277 ops,
3278 format!(
3279 "ALTER TABLE {} ADD COLUMN IF NOT EXISTS {} {}{}{};",
3280 qn(&td.module, &td.table),
3281 qi(&p.name),
3282 col_type_str(p),
3283 not_null,
3284 default
3285 ),
3286 );
3287 }
3288 for l in &td.links {
3289 if l.is_junction_backed() {
3290 continue;
3291 }
3292 let col = format!("{}_id", l.name);
3293 if existing_col_map.contains_key(col.as_str()) {
3294 continue;
3295 }
3296 let eff_default = resolve_link_default(l, schema);
3297 let needs_fill = for_migration && !l.nullable && eff_default.is_none() && fill_cols.contains(&col);
3298 let not_null = if l.nullable || needs_fill { "" } else { " NOT NULL" };
3299 let default = eff_default.map(|d| format!(" DEFAULT {}", d)).unwrap_or_default();
3300 push_tx(
3301 ops,
3302 format!(
3303 "ALTER TABLE {} ADD COLUMN IF NOT EXISTS {} uuid{}{};",
3304 qn(&td.module, &td.table),
3305 qi(&col),
3306 not_null,
3307 default
3308 ),
3309 );
3310 }
3311
3312 let type_changes: Vec<(&str, &str, bool)> = td
3316 .properties
3317 .iter()
3318 .filter_map(|p| {
3319 let cur = existing_col_map.get(p.name.as_str())?;
3320 if cur.is_generated {
3321 return None;
3322 }
3323 let target_type = col_type_str(p);
3324 pg_type_changed(target_type, &cur.pg_type).then_some((
3325 p.name.as_str(),
3326 target_type,
3327 cur.column_default.is_some(),
3328 ))
3329 })
3330 .collect();
3331 if !type_changes.is_empty() {
3332 let affected_views: Vec<(String, String, String)> = crate::export::interface_view_ddl_with_names(schema)
3337 .into_iter()
3338 .filter(|(m, n, _)| td.interfaces.contains(&format!("{}::{}", m, n)))
3339 .collect();
3340 for (m, n, _) in &affected_views {
3341 push_tx(ops, format!("DROP VIEW IF EXISTS {};", qn(m, n)));
3342 }
3343 for (col, target_type, has_default) in &type_changes {
3344 if *has_default {
3350 push_tx(
3351 ops,
3352 format!(
3353 "ALTER TABLE {} ALTER COLUMN {} DROP DEFAULT;",
3354 qn(&td.module, &td.table),
3355 qi(col)
3356 ),
3357 );
3358 }
3359 let placeholder = format!("cast_expr__{col}");
3367 let default_expr = if enum_target(schema, target_type) {
3372 let text_type = if target_type.ends_with("[]") { "text[]" } else { "text" };
3373 format!("{}::{text_type}::{target_type}", qi(col))
3374 } else {
3375 format!("{}::{target_type}", qi(col))
3376 };
3377 required_input.push(RequiredInput {
3378 placeholder: placeholder.clone(),
3379 prompt: format!(
3380 "Please specify a conversion expression to alter the type of property '{col}' of {}",
3381 verbosename_type(&td.module, &td.name),
3382 ),
3383 default_expr,
3384 type_name: format!("{}::{}", td.module, td.name),
3385 });
3386 push_tx(
3387 ops,
3388 format!(
3389 "ALTER TABLE {} ALTER COLUMN {} TYPE {} USING \\({});",
3390 qn(&td.module, &td.table),
3391 qi(col),
3392 target_type,
3393 placeholder
3394 ),
3395 );
3396 }
3397 for (_, _, ddl) in &affected_views {
3398 push_tx(ops, ddl.clone());
3399 }
3400 }
3401
3402 for p in &td.properties {
3404 let Some(cur) = existing_col_map.get(p.name.as_str()) else {
3405 continue;
3406 };
3407 if cur.is_generated {
3408 continue;
3409 }
3410 if !cur.nullable && p.nullable {
3411 push_tx(
3413 ops,
3414 format!(
3415 "ALTER TABLE {} ALTER COLUMN {} DROP NOT NULL;",
3416 qn(&td.module, &td.table),
3417 qi(&p.name)
3418 ),
3419 );
3420 } else if cur.nullable && !p.nullable && !for_migration {
3421 push_tx(
3423 ops,
3424 format!(
3425 "ALTER TABLE {} ALTER COLUMN {} SET NOT NULL;",
3426 qn(&td.module, &td.table),
3427 qi(&p.name)
3428 ),
3429 );
3430 }
3433
3434 let target_default = resolve_default(p, schema);
3436 let db_default = cur.column_default.as_deref();
3437 match (&target_default, db_default) {
3438 (Some(want), Some(have)) if want != have => {
3439 push_tx(
3440 ops,
3441 format!(
3442 "ALTER TABLE {} ALTER COLUMN {} SET DEFAULT {};",
3443 qn(&td.module, &td.table),
3444 qi(&p.name),
3445 want
3446 ),
3447 );
3448 }
3449 (Some(want), None) => {
3450 push_tx(
3451 ops,
3452 format!(
3453 "ALTER TABLE {} ALTER COLUMN {} SET DEFAULT {};",
3454 qn(&td.module, &td.table),
3455 qi(&p.name),
3456 want
3457 ),
3458 );
3459 }
3460 (None, Some(_)) => {
3461 push_tx(
3462 ops,
3463 format!(
3464 "ALTER TABLE {} ALTER COLUMN {} DROP DEFAULT;",
3465 qn(&td.module, &td.table),
3466 qi(&p.name)
3467 ),
3468 );
3469 }
3470 _ => {}
3471 }
3472 }
3473 for l in &td.links {
3474 if l.is_junction_backed() {
3475 continue;
3476 }
3477 let col = format!("{}_id", l.name);
3478 let Some(cur) = existing_col_map.get(col.as_str()) else {
3479 continue;
3480 };
3481 if !cur.nullable && l.nullable {
3482 push_tx(
3483 ops,
3484 format!(
3485 "ALTER TABLE {} ALTER COLUMN {} DROP NOT NULL;",
3486 qn(&td.module, &td.table),
3487 qi(&col)
3488 ),
3489 );
3490 } else if cur.nullable && !l.nullable && !for_migration {
3491 push_tx(
3492 ops,
3493 format!(
3494 "ALTER TABLE {} ALTER COLUMN {} SET NOT NULL;",
3495 qn(&td.module, &td.table),
3496 qi(&col)
3497 ),
3498 );
3499 }
3500
3501 let target_default = resolve_link_default(l, schema);
3502 let db_default = cur.column_default.as_deref();
3503 match (&target_default, db_default) {
3504 (Some(want), Some(have)) if want != have => {
3505 push_tx(
3506 ops,
3507 format!(
3508 "ALTER TABLE {} ALTER COLUMN {} SET DEFAULT {};",
3509 qn(&td.module, &td.table),
3510 qi(&col),
3511 want
3512 ),
3513 );
3514 }
3515 (Some(want), None) => {
3516 push_tx(
3517 ops,
3518 format!(
3519 "ALTER TABLE {} ALTER COLUMN {} SET DEFAULT {};",
3520 qn(&td.module, &td.table),
3521 qi(&col),
3522 want
3523 ),
3524 );
3525 }
3526 (None, Some(_)) => {
3527 push_tx(
3528 ops,
3529 format!(
3530 "ALTER TABLE {} ALTER COLUMN {} DROP DEFAULT;",
3531 qn(&td.module, &td.table),
3532 qi(&col)
3533 ),
3534 );
3535 }
3536 _ => {}
3537 }
3538 }
3539
3540 let target_cols: HashSet<String> = td
3542 .properties
3543 .iter()
3544 .map(|p| p.name.clone())
3545 .chain(
3546 td.links
3547 .iter()
3548 .filter(|l| !l.is_junction_backed())
3549 .map(|l| format!("{}_id", l.name)),
3550 )
3551 .collect();
3552 for col in &existing.columns {
3553 let n = col.name.as_str();
3554 if target_cols.contains(n) {
3555 continue;
3556 }
3557 if n.starts_with("__") && n.ends_with("__") {
3558 continue;
3559 }
3560 push_tx(
3561 ops,
3562 format!(
3563 "ALTER TABLE {} DROP COLUMN IF EXISTS {};",
3564 qn(&td.module, &td.table),
3565 qi(n)
3566 ),
3567 );
3568 }
3569}
3570
3571fn emit_fk_diff(
3574 td: &TypeDescriptor,
3575 existing: Option<&DbTable>,
3576 type_map: &HashMap<String, (&str, &str)>,
3577 polymorphic: &HashSet<String>,
3578 ops: &mut Vec<DiffOp>,
3579) {
3580 use crate::schema::{DeleteAction, DeleteSide};
3581
3582 let existing_fk_names: HashSet<&str> = existing
3583 .map(|e| e.foreign_keys.iter().map(|fk| fk.constraint_name.as_str()).collect())
3584 .unwrap_or_default();
3585
3586 for l in &td.links {
3587 if l.is_junction_backed() {
3588 continue;
3589 }
3590 if polymorphic.contains(&l.target) {
3593 continue;
3594 }
3595 let cname = format!("{}_{}_fkey", td.table, l.name);
3596 if existing_fk_names.contains(cname.as_str()) {
3597 continue;
3598 }
3599 let Some((tgt_module, tgt_table)) = type_map.get(&l.target) else {
3600 continue;
3601 };
3602 let needs_deferred = crate::export::needs_deferred_target_fk(&l.on_delete);
3609 let on_delete = l
3610 .on_delete
3611 .iter()
3612 .find(|p| p.side == DeleteSide::Target)
3613 .map(|p| match &p.action {
3614 DeleteAction::Restrict if needs_deferred => " DEFERRABLE INITIALLY DEFERRED",
3615 DeleteAction::Restrict => " ON DELETE RESTRICT",
3616 DeleteAction::DeferredRestrict => " DEFERRABLE INITIALLY DEFERRED",
3617 DeleteAction::DeleteSource => " ON DELETE CASCADE",
3618 DeleteAction::Allow => " ON DELETE SET NULL",
3619 _ => " ON DELETE RESTRICT",
3620 })
3621 .unwrap_or(if needs_deferred {
3622 " DEFERRABLE INITIALLY DEFERRED"
3623 } else {
3624 " ON DELETE RESTRICT"
3625 });
3626 push_tx(
3627 ops,
3628 format!(
3629 "ALTER TABLE {} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {}(id){};",
3630 qn(&td.module, &td.table),
3631 qi(&cname),
3632 qi(&format!("{}_id", l.name)),
3633 qn(tgt_module, tgt_table),
3634 on_delete
3635 ),
3636 );
3637 }
3638}
3639
3640#[allow(clippy::too_many_arguments)]
3648fn emit_junction_table(
3649 td: &TypeDescriptor,
3650 ml_name: &str,
3651 through: Option<&str>,
3652 schema: &SchemaDescriptor,
3653 single: bool,
3654 ops: &mut Vec<DiffOp>,
3655) {
3656 let jt_name = format!("{}.{}", td.table, ml_name);
3657 let src_on_delete = " ON DELETE CASCADE";
3658 let mut col_lines = format!(
3661 " source uuid NOT NULL REFERENCES {}(id){},\n target uuid NOT NULL",
3662 qn(&td.module, &td.table),
3663 src_on_delete,
3664 );
3665
3666 if let Some(through_qname) = through
3668 && let Some(through_td) = schema
3669 .types
3670 .iter()
3671 .find(|t| format!("{}::{}", t.module, t.name) == through_qname && t.junction)
3672 {
3673 for p in &through_td.properties {
3674 if p.name == "id" {
3675 continue;
3676 }
3677 let not_null = if p.nullable { "" } else { " NOT NULL" };
3678 col_lines.push_str(&format!(",\n {} {}{}", qi(&p.name), col_type_str(p), not_null));
3679 }
3680 }
3681
3682 let pk_clause = if single {
3683 "PRIMARY KEY (source)"
3684 } else {
3685 "PRIMARY KEY (source, target)"
3686 };
3687 push_tx(
3690 ops,
3691 format!(
3692 "CREATE TABLE IF NOT EXISTS {} (\n{},\n {}\n);",
3693 qn(&td.module, &jt_name),
3694 col_lines,
3695 pk_clause,
3696 ),
3697 );
3698}
3699
3700fn diff_states_inner(before: &DbState, after: &DbState) -> Vec<DiffOp> {
3703 let mut ops: Vec<DiffOp> = Vec::new();
3704
3705 let before_schemas: HashSet<&str> = before.schemas.iter().map(|s| s.as_str()).collect();
3706 let before_tables: HashMap<(&str, &str), &DbTable> = before
3707 .tables
3708 .iter()
3709 .map(|t| ((t.schema.as_str(), t.name.as_str()), t))
3710 .collect();
3711 let before_enums: HashMap<(&str, &str), &DbEnum> = before
3712 .enums
3713 .iter()
3714 .map(|e| ((e.schema.as_str(), e.name.as_str()), e))
3715 .collect();
3716 let before_domains: HashSet<(&str, &str)> = before
3717 .domains
3718 .iter()
3719 .map(|d| (d.schema.as_str(), d.name.as_str()))
3720 .collect();
3721
3722 for schema in &after.schemas {
3724 if schema == "default" {
3725 continue;
3726 } if !before_schemas.contains(schema.as_str()) {
3728 push_tx(&mut ops, format!("CREATE SCHEMA IF NOT EXISTS {};", pg_schema(schema)));
3729 }
3730 }
3731
3732 for e in &after.enums {
3734 match before_enums.get(&(e.schema.as_str(), e.name.as_str())) {
3735 None => {
3736 let members: Vec<String> = e
3737 .members
3738 .iter()
3739 .map(|m| format!("'{}'", m.replace('\'', "''")))
3740 .collect();
3741 push_tx(
3742 &mut ops,
3743 format!(
3744 "DO $$ BEGIN CREATE TYPE {}.{} AS ENUM ({}); \
3745 EXCEPTION WHEN duplicate_object THEN NULL; END $$;",
3746 pg_schema(&e.schema),
3747 qi(&e.name),
3748 members.join(", ")
3749 ),
3750 );
3751 }
3752 Some(existing) => {
3753 let existing_set: HashSet<&str> = existing.members.iter().map(|m| m.as_str()).collect();
3754 for member in &e.members {
3755 if !existing_set.contains(member.as_str()) {
3756 push_tx(
3757 &mut ops,
3758 format!(
3759 "ALTER TYPE {}.{} ADD VALUE IF NOT EXISTS '{}';",
3760 pg_schema(&e.schema),
3761 qi(&e.name),
3762 member.replace('\'', "''")
3763 ),
3764 );
3765 }
3766 }
3767 }
3768 }
3769 }
3770
3771 for d in &after.domains {
3773 if !before_domains.contains(&(d.schema.as_str(), d.name.as_str())) {
3774 push_tx(
3777 &mut ops,
3778 format!(
3779 "-- TODO: recreate domain {}.{} (reconstruct DDL from source migrations)",
3780 pg_schema(&d.schema),
3781 qi(&d.name)
3782 ),
3783 );
3784 }
3785 }
3786
3787 let mut new_tables: HashSet<(String, String)> = HashSet::new();
3789 for t in &after.tables {
3790 let key = (t.schema.as_str(), t.name.as_str());
3791 match before_tables.get(&key) {
3792 None => {
3793 emit_create_table_from_db(t, &mut ops);
3795 new_tables.insert((t.schema.clone(), t.name.clone()));
3796 }
3797 Some(before_t) => {
3798 emit_column_diff_from_db(t, before_t, &mut ops);
3800 }
3801 }
3802 }
3803
3804 for t in &after.tables {
3806 if let Some(before_t) = before_tables.get(&(t.schema.as_str(), t.name.as_str())) {
3807 let before_fk_names: HashSet<&str> = before_t
3808 .foreign_keys
3809 .iter()
3810 .map(|fk| fk.constraint_name.as_str())
3811 .collect();
3812 for fk in &t.foreign_keys {
3813 if !before_fk_names.contains(fk.constraint_name.as_str()) {
3814 push_tx(
3817 &mut ops,
3818 format!(
3819 "ALTER TABLE {}.{} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {}.{}(id);",
3820 pg_schema(&t.schema),
3821 qi(&t.name),
3822 qi(&fk.constraint_name),
3823 qi(&fk.local_column),
3824 pg_schema(&fk.ref_schema),
3825 qi(&fk.ref_table)
3826 ),
3827 );
3828 }
3829 }
3830 }
3831 }
3832
3833 for t in &after.tables {
3835 let table_is_new = new_tables.contains(&(t.schema.clone(), t.name.clone()));
3836 let before_indexes = before_tables
3837 .get(&(t.schema.as_str(), t.name.as_str()))
3838 .map(|bt| bt.indexes.as_slice())
3839 .unwrap_or_default();
3840 for idx in &t.indexes {
3841 let already_there = if idx.columns.is_empty() {
3847 before_indexes.iter().any(|b| b.name == idx.name)
3848 } else {
3849 before_indexes
3850 .iter()
3851 .any(|b| b.columns == idx.columns && b.is_unique == idx.is_unique)
3852 };
3853 if already_there {
3854 continue;
3855 }
3856 if idx.columns.is_empty() {
3859 continue;
3860 }
3861 let use_concurrently = !table_is_new;
3862 let concurrently = if use_concurrently { "CONCURRENTLY " } else { "" };
3863 let unique = if idx.is_unique { "UNIQUE " } else { "" };
3864 let idx_sql = format!(
3865 "CREATE {unique}INDEX {concurrently}IF NOT EXISTS {} ON {}.{} ({});",
3866 qi(&idx.name),
3867 pg_schema(&t.schema),
3868 qi(&t.name),
3869 idx.columns.iter().map(|c| qi(c)).collect::<Vec<_>>().join(", "),
3870 );
3871 ops.push(DiffOp {
3872 sql: idx_sql,
3873 non_transactional: use_concurrently,
3874 });
3875 }
3876 }
3877
3878 let after_tables: HashSet<(&str, &str)> = after
3880 .tables
3881 .iter()
3882 .map(|t| (t.schema.as_str(), t.name.as_str()))
3883 .collect();
3884 for t in &before.tables {
3885 if !after_tables.contains(&(t.schema.as_str(), t.name.as_str())) {
3886 push_tx(
3887 &mut ops,
3888 format!("DROP TABLE IF EXISTS {}.{} CASCADE;", pg_schema(&t.schema), qi(&t.name)),
3889 );
3890 }
3891 }
3892
3893 let after_enum_set: HashSet<(&str, &str)> = after
3895 .enums
3896 .iter()
3897 .map(|e| (e.schema.as_str(), e.name.as_str()))
3898 .collect();
3899 for e in &before.enums {
3900 if !after_enum_set.contains(&(e.schema.as_str(), e.name.as_str())) {
3901 push_tx(
3902 &mut ops,
3903 format!("DROP TYPE IF EXISTS {}.{} CASCADE;", pg_schema(&e.schema), qi(&e.name)),
3904 );
3905 }
3906 }
3907
3908 let after_schema_set: HashSet<&str> = after.schemas.iter().map(|s| s.as_str()).collect();
3910 for schema in &before.schemas {
3911 if schema == "default" {
3912 continue;
3913 } if !after_schema_set.contains(schema.as_str()) {
3915 push_tx(
3916 &mut ops,
3917 format!("DROP SCHEMA IF EXISTS {} CASCADE;", pg_schema(schema)),
3918 );
3919 }
3920 }
3921
3922 ops
3923}
3924
3925fn emit_create_table_from_db(t: &DbTable, ops: &mut Vec<DiffOp>) {
3926 let mut lines: Vec<String> = Vec::new();
3927 for col in &t.columns {
3928 let not_null = if col.nullable { "" } else { " NOT NULL" };
3929 if col.is_generated {
3930 lines.push(format!(
3932 " {} {} GENERATED ALWAYS AS (/* see source */) STORED",
3933 qi(&col.name),
3934 col.pg_type
3935 ));
3936 } else {
3937 lines.push(format!(" {} {}{}", qi(&col.name), col.pg_type, not_null));
3938 }
3939 }
3940 push_tx(
3941 ops,
3942 format!(
3943 "CREATE TABLE IF NOT EXISTS {}.{} (\n{}\n);",
3944 pg_schema(&t.schema),
3945 qi(&t.name),
3946 lines.join(",\n")
3947 ),
3948 );
3949}
3950
3951fn emit_column_diff_from_db(after: &DbTable, before: &DbTable, ops: &mut Vec<DiffOp>) {
3952 let before_cols: HashSet<&str> = before.columns.iter().map(|c| c.name.as_str()).collect();
3953 let after_cols: HashSet<&str> = after.columns.iter().map(|c| c.name.as_str()).collect();
3954
3955 for col in &after.columns {
3956 if !before_cols.contains(col.name.as_str()) {
3957 let not_null = if col.nullable { "" } else { " NOT NULL" };
3958 push_tx(
3959 ops,
3960 format!(
3961 "ALTER TABLE {}.{} ADD COLUMN IF NOT EXISTS {} {}{};",
3962 pg_schema(&after.schema),
3963 qi(&after.name),
3964 qi(&col.name),
3965 col.pg_type,
3966 not_null
3967 ),
3968 );
3969 }
3970 }
3971 for col in &before.columns {
3972 if !after_cols.contains(col.name.as_str()) {
3973 push_tx(
3974 ops,
3975 format!(
3976 "ALTER TABLE {}.{} DROP COLUMN IF EXISTS {};",
3977 pg_schema(&after.schema),
3978 qi(&after.name),
3979 qi(&col.name)
3980 ),
3981 );
3982 }
3983 }
3984}
3985
3986#[cfg(test)]
3989mod tests {
3990 use super::*;
3991 use crate::schema::{EnumDescriptor, LinkDescriptor, PropertyDescriptor, SchemaDescriptor, TypeDescriptor};
3992
3993 fn empty_state() -> DbState {
3994 DbState::default()
3995 }
3996
3997 fn prop(name: &str, pg_type: &str, nullable: bool) -> PropertyDescriptor {
3998 PropertyDescriptor {
3999 name: name.into(),
4000 pg_type: pg_type.into(),
4001 nullable,
4002 default_sql: if name == "id" { Some("uuidv7()".into()) } else { None },
4003 default_pyql: None,
4004 description: None,
4005 check_constraints: vec![],
4006 is_exclusive: name == "id",
4007 is_pk: name == "id",
4008 is_readonly: name == "id",
4009 rewrites: vec![],
4010 tuple_members: None,
4011 column_type: None,
4012 }
4013 }
4014
4015 fn simple_type(module: &str, name: &str, table: &str) -> TypeDescriptor {
4016 TypeDescriptor {
4017 name: name.into(),
4018 module: module.into(),
4019 table: table.into(),
4020 abstract_: false,
4021 materialized: false,
4022 description: None,
4023 parents: vec![],
4024 interfaces: vec![],
4025 bases: vec![],
4026 properties: vec![prop("id", "uuid", false), prop("name", "text", true)],
4027 links: vec![],
4028 multilinks: vec![],
4029 computed: vec![],
4030 constraints: vec![],
4031 indexes: vec![],
4032 partition: None,
4033 vector_indexes: vec![],
4034 search_indexes: vec![],
4035 triggers: vec![],
4036 junction: false,
4037 signals: vec![],
4038 }
4039 }
4040
4041 #[test]
4044 fn test_schema_content_changed_detects_a_readonly_only_flip() {
4045 let before = simple_type("default", "Person", "Person");
4049 let mut after = before.clone();
4050 after.properties[1].is_readonly = true; assert_ne!(before.properties[1].is_readonly, after.properties[1].is_readonly);
4052
4053 let schema_before = SchemaDescriptor {
4054 types: vec![before],
4055 scalars: vec![],
4056 enums: vec![],
4057 named_tuples: vec![],
4058 globals: vec![],
4059 functions: vec![],
4060 aliases: vec![],
4061 channels: vec![],
4062 ..Default::default()
4063 };
4064 let schema_after = SchemaDescriptor {
4065 types: vec![after],
4066 scalars: vec![],
4067 enums: vec![],
4068 named_tuples: vec![],
4069 globals: vec![],
4070 functions: vec![],
4071 aliases: vec![],
4072 channels: vec![],
4073 ..Default::default()
4074 };
4075 assert!(schema_content_changed(&schema_after, Some(&schema_before)));
4076 }
4077
4078 #[test]
4079 fn test_schema_content_changed_detects_a_new_rewrite() {
4080 let before = simple_type("default", "Person", "Person");
4081 let mut after = before.clone();
4082 after.properties[1].rewrites.push(crate::schema::RewriteEntry {
4083 on: 1,
4084 handler: "str_upper(.name)".into(),
4085 });
4086
4087 let schema_before = SchemaDescriptor {
4088 types: vec![before],
4089 scalars: vec![],
4090 enums: vec![],
4091 named_tuples: vec![],
4092 globals: vec![],
4093 functions: vec![],
4094 aliases: vec![],
4095 channels: vec![],
4096 ..Default::default()
4097 };
4098 let schema_after = SchemaDescriptor {
4099 types: vec![after],
4100 scalars: vec![],
4101 enums: vec![],
4102 named_tuples: vec![],
4103 globals: vec![],
4104 functions: vec![],
4105 aliases: vec![],
4106 channels: vec![],
4107 ..Default::default()
4108 };
4109 assert!(schema_content_changed(&schema_after, Some(&schema_before)));
4110 }
4111
4112 #[test]
4113 fn test_schema_content_changed_is_false_for_identical_schemas() {
4114 let t = simple_type("default", "Person", "Person");
4115 let schema = SchemaDescriptor {
4116 types: vec![t],
4117 scalars: vec![],
4118 enums: vec![],
4119 named_tuples: vec![],
4120 globals: vec![],
4121 functions: vec![],
4122 aliases: vec![],
4123 channels: vec![],
4124 ..Default::default()
4125 };
4126 let other = schema.clone();
4127 assert!(!schema_content_changed(&schema, Some(&other)));
4128 }
4129
4130 #[test]
4131 fn test_schema_content_changed_true_against_none_when_target_is_non_empty() {
4132 let t = simple_type("default", "Person", "Person");
4133 let schema = SchemaDescriptor {
4134 types: vec![t],
4135 scalars: vec![],
4136 enums: vec![],
4137 named_tuples: vec![],
4138 globals: vec![],
4139 functions: vec![],
4140 aliases: vec![],
4141 channels: vec![],
4142 ..Default::default()
4143 };
4144 assert!(
4145 schema_content_changed(&schema, None),
4146 "no prior snapshot at all must count as changed"
4147 );
4148 }
4149
4150 #[test]
4151 fn test_schema_content_changed_false_against_none_when_target_is_also_empty() {
4152 let schema = SchemaDescriptor::default();
4153 assert!(!schema_content_changed(&schema, None));
4154 }
4155
4156 #[test]
4157 fn test_schema_content_changed_still_true_when_ddl_visible_things_also_changed() {
4158 let schema_before = SchemaDescriptor::default();
4162 let schema_after = SchemaDescriptor {
4163 types: vec![simple_type("default", "Person", "Person")],
4164 scalars: vec![],
4165 enums: vec![],
4166 named_tuples: vec![],
4167 globals: vec![],
4168 functions: vec![],
4169 aliases: vec![],
4170 channels: vec![],
4171 ..Default::default()
4172 };
4173 assert!(schema_content_changed(&schema_after, Some(&schema_before)));
4174 }
4175
4176 #[test]
4177 fn test_schema_content_changed_detects_a_new_channel() {
4178 let schema_before = SchemaDescriptor::default();
4183 let schema_after = SchemaDescriptor {
4184 channels: vec![crate::schema::ChannelDescriptor {
4185 name: "UserUpdates".into(),
4186 module: "default".into(),
4187 wire_name: "default__user_updates".into(),
4188 payload: crate::schema::ChannelPayload::Scalar("text".into()),
4189 description: None,
4190 }],
4191 ..Default::default()
4192 };
4193 assert!(schema_content_changed(&schema_after, Some(&schema_before)));
4194 }
4195
4196 #[test]
4197 fn test_new_schema_and_table() {
4198 let schema = SchemaDescriptor {
4199 types: vec![simple_type("catalog", "Product", "Product")],
4200 scalars: vec![],
4201 enums: vec![],
4202 named_tuples: vec![],
4203 globals: vec![],
4204 functions: vec![],
4205 aliases: vec![],
4206 channels: vec![],
4207 ..Default::default()
4208 };
4209 let ops = diff_schema(&schema, &empty_state()).unwrap();
4210 let joined = ops.join("\n");
4211 assert!(
4212 joined.contains("CREATE SCHEMA IF NOT EXISTS \"catalog\""),
4213 "got:\n{joined}"
4214 );
4215 assert!(
4216 joined.contains("CREATE TABLE IF NOT EXISTS \"catalog\".\"Product\""),
4217 "got:\n{joined}"
4218 );
4219 assert!(
4220 joined.contains("CREATE OR REPLACE TRIGGER pylon_cache_invalidate\n AFTER INSERT OR UPDATE OR DELETE ON \"catalog\".\"Product\""),
4221 "new table must get the cache-invalidation trigger; got:\n{joined}"
4222 );
4223 }
4224
4225 fn widget_with_trigger(on: u8, timing: &str, handler: &str) -> TypeDescriptor {
4226 let mut t = simple_type("default", "Widget", "Widget");
4227 t.triggers = vec![crate::schema::TriggerDescriptor {
4228 on,
4229 timing: timing.into(),
4230 handler: handler.into(),
4231 }];
4232 t
4233 }
4234
4235 #[test]
4236 fn test_new_table_with_user_trigger_emits_the_compiled_trigger_ddl() {
4237 let schema = SchemaDescriptor {
4244 types: vec![widget_with_trigger(
4245 1,
4246 "After",
4247 "update Widget set { name := __new__.name }",
4248 )],
4249 scalars: vec![],
4250 enums: vec![],
4251 named_tuples: vec![],
4252 globals: vec![],
4253 functions: vec![],
4254 aliases: vec![],
4255 channels: vec![],
4256 ..Default::default()
4257 };
4258 let ops = diff_schema(&schema, &empty_state()).unwrap();
4259 let joined = ops.join("\n");
4260 assert!(joined.contains("NEW.\"name\""), "got:\n{joined}");
4261 }
4262
4263 #[test]
4264 fn a_trigger_emitted_by_an_older_build_is_replaced() {
4265 let schema = SchemaDescriptor {
4276 types: vec![widget_with_trigger(
4277 1,
4278 "After",
4279 "update Widget set { name := __new__.name }",
4280 )],
4281 scalars: vec![],
4282 enums: vec![],
4283 named_tuples: vec![],
4284 globals: vec![],
4285 functions: vec![],
4286 aliases: vec![],
4287 channels: vec![],
4288 ..Default::default()
4289 };
4290 let mut stale = schema_to_db_state(&schema);
4291 let current_name = stale
4292 .tables
4293 .iter()
4294 .flat_map(|t| t.triggers.iter().cloned())
4295 .find(|n| n.starts_with("Widget_"))
4296 .expect("the fixture should project a Widget trigger");
4297 let stale_name = "Widget_trg_0badc0de".to_string();
4298 for table in &mut stale.tables {
4299 for trigger in &mut table.triggers {
4300 if *trigger == current_name {
4301 *trigger = stale_name.clone();
4302 }
4303 }
4304 }
4305
4306 let joined = diff_schema(&schema, &stale).unwrap().join("\n");
4307 assert!(
4308 joined.contains(&format!("DROP TRIGGER IF EXISTS \"{stale_name}\"")),
4309 "the stale trigger should be dropped, got:\n{joined}"
4310 );
4311 assert!(
4312 joined.contains(¤t_name),
4313 "the current trigger should be created, got:\n{joined}"
4314 );
4315 assert!(
4318 joined.contains(&format!("DROP FUNCTION IF EXISTS \"public\".\"{stale_name}\"()")),
4319 "the orphaned function should be dropped, got:\n{joined}"
4320 );
4321 }
4322
4323 #[test]
4324 fn test_user_trigger_already_present_in_offline_baseline_produces_no_further_steps() {
4325 let schema = SchemaDescriptor {
4330 types: vec![widget_with_trigger(
4331 1,
4332 "After",
4333 "update Widget set { name := __new__.name }",
4334 )],
4335 scalars: vec![],
4336 enums: vec![],
4337 named_tuples: vec![],
4338 globals: vec![],
4339 functions: vec![],
4340 aliases: vec![],
4341 channels: vec![],
4342 ..Default::default()
4343 };
4344 let baseline = schema_to_db_state(&schema);
4345 let steps = diff_schema_steps(&schema, &baseline, &HashMap::new()).unwrap();
4346 assert!(
4347 steps.is_empty(),
4348 "expected zero further migration steps, got: {steps:?}"
4349 );
4350 }
4351
4352 #[test]
4353 fn a_link_target_gaining_a_subtype_loses_its_foreign_key() {
4354 let mut order = simple_type("default", "Order", "Order");
4355 order.links.push(LinkDescriptor {
4356 name: "customer".into(),
4357 target: "default::Person".into(),
4358 nullable: false,
4359 through: None,
4360 description: None,
4361 default_pyql: None,
4362 is_exclusive: false,
4363 is_readonly: false,
4364 rewrites: vec![],
4365 on_delete: vec![],
4366 });
4367 let before = SchemaDescriptor {
4368 types: vec![order, simple_type("default", "Person", "Person")],
4369 scalars: vec![],
4370 enums: vec![],
4371 named_tuples: vec![],
4372 globals: vec![],
4373 functions: vec![],
4374 aliases: vec![],
4375 channels: vec![],
4376 ..Default::default()
4377 };
4378 let mut after = before.clone();
4379 let mut vip = simple_type("default", "Vip", "Vip");
4380 vip.bases = vec!["default::Person".into()];
4381 after.types.push(vip);
4382
4383 let joined = diff_schema(&after, &schema_to_db_state(&before)).unwrap().join("\n");
4384 assert!(
4385 joined.contains("ALTER TABLE \"public\".\"Order\" DROP CONSTRAINT IF EXISTS \"Order_customer_fkey\""),
4386 "got:\n{joined}"
4387 );
4388 let settled = diff_schema_steps(&after, &schema_to_db_state(&after), &HashMap::new()).unwrap();
4389 assert!(settled.is_empty(), "nothing further once applied, got: {settled:?}");
4390 }
4391
4392 #[test]
4393 fn test_new_table_with_plain_link_gets_its_fk_constraint() {
4394 let mut order = simple_type("default", "Order", "Order");
4400 order.links.push(LinkDescriptor {
4401 name: "customer".into(),
4402 target: "default::Person".into(),
4403 nullable: false,
4404 through: None,
4405 description: None,
4406 default_pyql: None,
4407 is_exclusive: false,
4408 is_readonly: false,
4409 rewrites: vec![],
4410 on_delete: vec![],
4411 });
4412 let schema = SchemaDescriptor {
4413 types: vec![order, simple_type("default", "Person", "Person")],
4414 scalars: vec![],
4415 enums: vec![],
4416 named_tuples: vec![],
4417 globals: vec![],
4418 functions: vec![],
4419 aliases: vec![],
4420 channels: vec![],
4421 ..Default::default()
4422 };
4423 let ops = diff_schema(&schema, &empty_state()).unwrap();
4424 let joined = ops.join("\n");
4425 assert!(
4426 joined.contains("ADD CONSTRAINT \"Order_customer_fkey\" FOREIGN KEY (\"customer_id\") REFERENCES \"public\".\"Person\"(id)"),
4427 "new table's plain link must get its FK constraint in the same diff; got:\n{joined}"
4428 );
4429 }
4430
4431 #[test]
4432 fn test_an_existing_exclusive_multilink_junction_gains_its_unique_target() {
4433 use crate::schema::MultiLinkDescriptor;
4434
4435 let mut person = simple_type("default", "Person", "Person");
4436 person.multilinks.push(MultiLinkDescriptor {
4437 name: "keys".into(),
4438 target: "default::Key".into(),
4439 through: None,
4440 nullable: false,
4441 description: None,
4442 default_pyql: None,
4443 on_delete: vec![],
4444 is_exclusive: true,
4445 });
4446 let schema = SchemaDescriptor {
4447 types: vec![person, simple_type("default", "Key", "Key")],
4448 scalars: vec![],
4449 enums: vec![],
4450 named_tuples: vec![],
4451 globals: vec![],
4452 functions: vec![],
4453 aliases: vec![],
4454 channels: vec![],
4455 ..Default::default()
4456 };
4457 let mut state = schema_to_db_state(&schema);
4458 assert!(diff_schema(&schema, &state).unwrap().is_empty());
4459
4460 for table in state.tables.iter_mut().filter(|t| t.name == "Person.keys") {
4461 table.indexes.clear();
4462 }
4463 let joined = diff_schema(&schema, &state).unwrap().join("\n");
4464 assert!(
4465 joined.contains("CREATE UNIQUE INDEX IF NOT EXISTS \"Person.keys_target_key\" ON \"public\".\"Person.keys\" (\"target\")"),
4466 "got:\n{joined}"
4467 );
4468 }
4469
4470 #[test]
4471 fn test_cache_invalidate_trigger_not_duplicated_for_junction_through_type() {
4472 use crate::schema::MultiLinkDescriptor;
4473
4474 let mut product = simple_type("default", "Product", "Product");
4475 product.multilinks.push(MultiLinkDescriptor {
4476 name: "tags".into(),
4477 target: "default::Tag".into(),
4478 through: Some("default::ProductTag".into()),
4479 nullable: false,
4480 description: None,
4481 default_pyql: None,
4482 on_delete: vec![],
4483 is_exclusive: false,
4484 });
4485 let mut junction = simple_type("default", "ProductTag", "Product.tags");
4486 junction.junction = true;
4487
4488 let schema = SchemaDescriptor {
4489 types: vec![product, junction, simple_type("default", "Tag", "Tag")],
4490 scalars: vec![],
4491 enums: vec![],
4492 named_tuples: vec![],
4493 globals: vec![],
4494 functions: vec![],
4495 aliases: vec![],
4496 channels: vec![],
4497 ..Default::default()
4498 };
4499 let ops = diff_schema(&schema, &empty_state()).unwrap();
4500 let trigger_count = ops
4501 .iter()
4502 .filter(|op| op.contains("AFTER INSERT OR UPDATE OR DELETE ON \"public\".\"Product.tags\""))
4503 .count();
4504 assert_eq!(
4505 trigger_count, 1,
4506 "junction table's own td.table and the owning type's multilink both resolve to \
4507 the same physical table — must be deduped to one trigger, got {trigger_count} in: {ops:?}"
4508 );
4509 }
4510
4511 fn person_with_junction_backed_spouse() -> SchemaDescriptor {
4512 let mut person = simple_type("default", "Person", "Person");
4513 person.links.push(LinkDescriptor {
4514 name: "spouse".into(),
4515 target: "default::Person".into(),
4516 nullable: true,
4517 through: Some("default::Marriage".into()),
4518 description: None,
4519 default_pyql: None,
4520 is_exclusive: true,
4521 is_readonly: false,
4522 rewrites: vec![],
4523 on_delete: vec![],
4524 });
4525 let mut junction = simple_type("default", "Marriage", "Person.spouse");
4526 junction.junction = true;
4527
4528 SchemaDescriptor {
4529 types: vec![person, junction],
4530 scalars: vec![],
4531 enums: vec![],
4532 named_tuples: vec![],
4533 globals: vec![],
4534 functions: vec![],
4535 aliases: vec![],
4536 channels: vec![],
4537 ..Default::default()
4538 }
4539 }
4540
4541 #[test]
4542 fn test_junction_backed_single_link_creates_junction_table_from_scratch() {
4543 let schema = person_with_junction_backed_spouse();
4544 let ops = diff_schema(&schema, &empty_state()).unwrap();
4545 let joined = ops.join("\n");
4546 assert!(
4547 !joined.contains("spouse_id"),
4548 "no {{name}}_id column/FK for a junction-backed link, got:\n{joined}"
4549 );
4550 assert!(
4551 joined.contains("CREATE TABLE IF NOT EXISTS \"public\".\"Person.spouse\""),
4552 "got:\n{joined}"
4553 );
4554 assert!(joined.contains("PRIMARY KEY (source)"), "got:\n{joined}");
4555 assert!(
4556 joined.contains("CREATE UNIQUE INDEX IF NOT EXISTS \"Person.spouse_target_key\" ON \"public\".\"Person.spouse\" (\"target\")"),
4557 "got:\n{joined}"
4558 );
4559 }
4560
4561 #[test]
4562 fn test_junction_backed_single_link_diff_is_idempotent_once_applied() {
4563 let schema = person_with_junction_backed_spouse();
4571 let state = DbState {
4572 schemas: vec![],
4573 tables: vec![
4574 DbTable {
4575 schema: "default".into(),
4576 name: "Person".into(),
4577 columns: vec![
4578 DbColumn {
4579 name: "id".into(),
4580 pg_type: "uuid".into(),
4581 nullable: false,
4582 is_generated: false,
4583 column_default: Some("uuidv7()".into()),
4584 },
4585 DbColumn {
4586 name: "name".into(),
4587 pg_type: "text".into(),
4588 nullable: true,
4589 is_generated: false,
4590 column_default: None,
4591 },
4592 ],
4593 foreign_keys: vec![],
4594 indexes: vec![],
4595 checks: vec![],
4596 triggers: vec!["pylon_cache_invalidate".into()],
4597 },
4598 DbTable {
4599 schema: "default".into(),
4600 name: "Person.spouse".into(),
4601 columns: vec![
4602 DbColumn {
4603 name: "source".into(),
4604 pg_type: "uuid".into(),
4605 nullable: false,
4606 is_generated: false,
4607 column_default: None,
4608 },
4609 DbColumn {
4610 name: "target".into(),
4611 pg_type: "uuid".into(),
4612 nullable: false,
4613 is_generated: false,
4614 column_default: None,
4615 },
4616 DbColumn {
4617 name: "name".into(),
4618 pg_type: "text".into(),
4619 nullable: true,
4620 is_generated: false,
4621 column_default: None,
4622 },
4623 ],
4624 foreign_keys: vec![
4625 DbForeignKey {
4626 constraint_name: "Person_spouse_source_fkey".into(),
4627 local_column: "source".into(),
4628 ref_schema: "default".into(),
4629 ref_table: "Person".into(),
4630 },
4631 DbForeignKey {
4632 constraint_name: "Person_spouse_target_fkey".into(),
4633 local_column: "target".into(),
4634 ref_schema: "default".into(),
4635 ref_table: "Person".into(),
4636 },
4637 ],
4638 indexes: vec![DbIndex {
4639 name: "Person.spouse_target_key".into(),
4640 is_unique: true,
4641 method: "btree".into(),
4642 columns: vec!["target".into()],
4643 predicate: None,
4644 key: None,
4645 }],
4646 checks: vec![],
4647 triggers: vec!["pylon_cache_invalidate".into()],
4648 },
4649 ],
4650 enums: vec![],
4651 domains: vec![],
4652 ..DbState::default()
4653 };
4654 let ops = diff_schema(&schema, &state).unwrap();
4655 assert!(
4656 ops.is_empty(),
4657 "already-migrated junction-backed single link must diff to no ops, got: {:?}",
4658 ops
4659 );
4660 }
4661
4662 #[test]
4663 fn test_cache_invalidate_trigger_backfilled_on_pre_existing_table() {
4664 let schema = SchemaDescriptor {
4665 types: vec![simple_type("default", "Person", "Person")],
4666 scalars: vec![],
4667 enums: vec![],
4668 named_tuples: vec![],
4669 globals: vec![],
4670 functions: vec![],
4671 aliases: vec![],
4672 channels: vec![],
4673 ..Default::default()
4674 };
4675 let state = DbState {
4676 schemas: vec!["default".into()],
4677 tables: vec![DbTable {
4678 schema: "default".into(),
4679 name: "Person".into(),
4680 columns: vec![
4681 DbColumn {
4682 name: "id".into(),
4683 pg_type: "uuid".into(),
4684 nullable: false,
4685 is_generated: false,
4686 column_default: Some("uuidv7()".into()),
4687 },
4688 DbColumn {
4689 name: "name".into(),
4690 pg_type: "text".into(),
4691 nullable: true,
4692 is_generated: false,
4693 column_default: None,
4694 },
4695 ],
4696 foreign_keys: vec![],
4697 indexes: vec![],
4698 checks: vec![],
4699 triggers: vec![], }],
4701 enums: vec![],
4702 domains: vec![],
4703 ..DbState::default()
4704 };
4705 let ops = diff_schema(&schema, &state).unwrap();
4706 let joined = ops.join("\n");
4707 assert!(
4708 joined.contains("CREATE OR REPLACE TRIGGER pylon_cache_invalidate\n AFTER INSERT OR UPDATE OR DELETE ON \"public\".\"Person\""),
4709 "pre-existing table missing the trigger must get it backfilled; got:\n{joined}"
4710 );
4711 }
4712
4713 #[test]
4714 fn test_cache_invalidate_trigger_not_dropped_when_already_present() {
4715 let schema = SchemaDescriptor {
4716 types: vec![simple_type("default", "Person", "Person")],
4717 scalars: vec![],
4718 enums: vec![],
4719 named_tuples: vec![],
4720 globals: vec![],
4721 functions: vec![],
4722 aliases: vec![],
4723 channels: vec![],
4724 ..Default::default()
4725 };
4726 let state = DbState {
4727 schemas: vec!["default".into()],
4728 tables: vec![DbTable {
4729 schema: "default".into(),
4730 name: "Person".into(),
4731 columns: vec![
4732 DbColumn {
4733 name: "id".into(),
4734 pg_type: "uuid".into(),
4735 nullable: false,
4736 is_generated: false,
4737 column_default: Some("uuidv7()".into()),
4738 },
4739 DbColumn {
4740 name: "name".into(),
4741 pg_type: "text".into(),
4742 nullable: true,
4743 is_generated: false,
4744 column_default: None,
4745 },
4746 ],
4747 foreign_keys: vec![],
4748 indexes: vec![],
4749 checks: vec![],
4750 triggers: vec!["pylon_cache_invalidate".into()],
4751 }],
4752 enums: vec![],
4753 domains: vec![],
4754 ..DbState::default()
4755 };
4756 let ops = diff_schema(&schema, &state).unwrap();
4757 assert!(
4758 ops.iter()
4759 .all(|op| !op.contains("DROP TRIGGER") && !op.contains("pylon_cache_invalidate")),
4760 "already-present trigger must not be re-created or dropped; got: {:?}",
4761 ops
4762 );
4763 }
4764
4765 #[test]
4766 fn test_no_ops_when_in_sync() {
4767 let schema = SchemaDescriptor {
4768 types: vec![simple_type("default", "Person", "Person")],
4769 scalars: vec![],
4770 enums: vec![],
4771 named_tuples: vec![],
4772 globals: vec![],
4773 functions: vec![],
4774 aliases: vec![],
4775 channels: vec![],
4776 ..Default::default()
4777 };
4778 let state = DbState {
4779 schemas: vec!["default".into()],
4780 tables: vec![DbTable {
4781 schema: "default".into(),
4782 name: "Person".into(),
4783 columns: vec![
4784 DbColumn {
4785 name: "id".into(),
4786 pg_type: "uuid".into(),
4787 nullable: false,
4788 is_generated: false,
4789 column_default: Some("uuidv7()".into()),
4790 },
4791 DbColumn {
4792 name: "name".into(),
4793 pg_type: "text".into(),
4794 nullable: true,
4795 is_generated: false,
4796 column_default: None,
4797 },
4798 ],
4799 foreign_keys: vec![],
4800 indexes: vec![],
4801 checks: vec![],
4802 triggers: vec!["pylon_cache_invalidate".into()],
4803 }],
4804 enums: vec![],
4805 domains: vec![],
4806 ..DbState::default()
4807 };
4808 let ops = diff_schema(&schema, &state).unwrap();
4809 assert!(ops.is_empty(), "expected no ops, got: {:?}", ops);
4810 }
4811
4812 #[test]
4813 fn test_add_column() {
4814 let mut td = simple_type("default", "Person", "Person");
4815 td.properties.push(prop("email", "text", true));
4816 let schema = SchemaDescriptor {
4817 types: vec![td],
4818 scalars: vec![],
4819 enums: vec![],
4820 named_tuples: vec![],
4821 globals: vec![],
4822 functions: vec![],
4823 aliases: vec![],
4824 channels: vec![],
4825 ..Default::default()
4826 };
4827 let state = DbState {
4828 schemas: vec!["default".into()],
4829 tables: vec![DbTable {
4830 schema: "default".into(),
4831 name: "Person".into(),
4832 columns: vec![
4833 DbColumn {
4834 name: "id".into(),
4835 pg_type: "uuid".into(),
4836 nullable: false,
4837 is_generated: false,
4838 column_default: Some("uuidv7()".into()),
4839 },
4840 DbColumn {
4841 name: "name".into(),
4842 pg_type: "text".into(),
4843 nullable: true,
4844 is_generated: false,
4845 column_default: None,
4846 },
4847 ],
4848 foreign_keys: vec![],
4849 indexes: vec![],
4850 checks: vec![],
4851 triggers: vec![],
4852 }],
4853 enums: vec![],
4854 domains: vec![],
4855 ..DbState::default()
4856 };
4857 let ops = diff_schema(&schema, &state).unwrap();
4858 let joined = ops.join("\n");
4859 assert!(joined.contains("ADD COLUMN IF NOT EXISTS \"email\""), "got:\n{joined}");
4860 }
4861
4862 #[test]
4863 fn test_property_type_change_emits_alter_column_type() {
4864 let mut td = simple_type("default", "Person", "Person");
4867 td.properties.push(prop("rating", "int8", true));
4868 let schema = SchemaDescriptor {
4869 types: vec![td],
4870 scalars: vec![],
4871 enums: vec![],
4872 named_tuples: vec![],
4873 globals: vec![],
4874 functions: vec![],
4875 aliases: vec![],
4876 channels: vec![],
4877 ..Default::default()
4878 };
4879 let state = DbState {
4880 schemas: vec!["default".into()],
4881 tables: vec![DbTable {
4882 schema: "default".into(),
4883 name: "Person".into(),
4884 columns: vec![
4885 DbColumn {
4886 name: "id".into(),
4887 pg_type: "uuid".into(),
4888 nullable: false,
4889 is_generated: false,
4890 column_default: Some("uuidv7()".into()),
4891 },
4892 DbColumn {
4893 name: "name".into(),
4894 pg_type: "text".into(),
4895 nullable: true,
4896 is_generated: false,
4897 column_default: None,
4898 },
4899 DbColumn {
4900 name: "rating".into(),
4901 pg_type: "text".into(),
4902 nullable: true,
4903 is_generated: false,
4904 column_default: None,
4905 },
4906 ],
4907 foreign_keys: vec![],
4908 indexes: vec![],
4909 checks: vec![],
4910 triggers: vec![],
4911 }],
4912 enums: vec![],
4913 domains: vec![],
4914 ..DbState::default()
4915 };
4916 let ops = diff_schema(&schema, &state).unwrap();
4917 let joined = ops.join("\n");
4918 assert!(
4919 joined.contains(
4920 "ALTER TABLE \"public\".\"Person\" ALTER COLUMN \"rating\" TYPE int8 USING \"rating\"::int8;"
4921 ),
4922 "got:\n{joined}"
4923 );
4924 }
4925
4926 #[test]
4927 fn test_a_column_moving_between_enums_converts_through_text() {
4928 let mut td = simple_type("default", "Person", "Person");
4933 td.properties.push(prop("verb", "\"public\".\"HttpMethod\"", true));
4934 let schema = SchemaDescriptor {
4935 types: vec![td],
4936 enums: vec![crate::schema::EnumDescriptor {
4937 name: "HttpMethod".into(),
4938 module: "default".into(),
4939 members: vec!["GET".into(), "POST".into()],
4940 }],
4941 ..Default::default()
4942 };
4943 let state = DbState {
4944 schemas: vec!["default".into()],
4945 tables: vec![DbTable {
4946 schema: "default".into(),
4947 name: "Person".into(),
4948 columns: vec![
4949 DbColumn {
4950 name: "id".into(),
4951 pg_type: "uuid".into(),
4952 nullable: false,
4953 is_generated: false,
4954 column_default: Some("uuidv7()".into()),
4955 },
4956 DbColumn {
4957 name: "verb".into(),
4958 pg_type: "\"public\".\"Method\"".into(),
4959 nullable: true,
4960 is_generated: false,
4961 column_default: None,
4962 },
4963 ],
4964 foreign_keys: vec![],
4965 indexes: vec![],
4966 checks: vec![],
4967 triggers: vec![],
4968 }],
4969 enums: vec![],
4970 domains: vec![],
4971 ..DbState::default()
4972 };
4973 let joined = diff_schema(&schema, &state).unwrap().join("\n");
4974 assert!(
4975 joined.contains(r#"USING "verb"::text::"public"."HttpMethod";"#),
4976 "the conversion should go through text:\n{joined}"
4977 );
4978 }
4979
4980 #[test]
4981 fn test_a_column_default_is_dropped_before_its_type_changes() {
4982 let mut td = simple_type("default", "Person", "Person");
4986 let mut rating = prop("rating", "int8", true);
4987 rating.default_sql = Some("'1'".into());
4988 td.properties.push(rating);
4989 let schema = SchemaDescriptor {
4990 types: vec![td],
4991 ..Default::default()
4992 };
4993 let state = DbState {
4994 schemas: vec!["default".into()],
4995 tables: vec![DbTable {
4996 schema: "default".into(),
4997 name: "Person".into(),
4998 columns: vec![
4999 DbColumn {
5000 name: "id".into(),
5001 pg_type: "uuid".into(),
5002 nullable: false,
5003 is_generated: false,
5004 column_default: Some("uuidv7()".into()),
5005 },
5006 DbColumn {
5007 name: "rating".into(),
5008 pg_type: "text".into(),
5009 nullable: true,
5010 is_generated: false,
5011 column_default: Some("'x'::text".into()),
5012 },
5013 ],
5014 foreign_keys: vec![],
5015 indexes: vec![],
5016 checks: vec![],
5017 triggers: vec![],
5018 }],
5019 enums: vec![],
5020 domains: vec![],
5021 ..DbState::default()
5022 };
5023 let joined = diff_schema(&schema, &state).unwrap().join("\n");
5024 let Some(drop) = joined.find(r#"ALTER COLUMN "rating" DROP DEFAULT;"#) else {
5025 panic!("the default should be dropped:\n{joined}");
5026 };
5027 let Some(retype) = joined.find(r#"ALTER COLUMN "rating" TYPE int8"#) else {
5028 panic!("the type should change:\n{joined}");
5029 };
5030 assert!(drop < retype, "the drop must come first:\n{joined}");
5031 }
5032
5033 #[test]
5034 fn test_property_type_change_surfaces_a_required_cast_expression_step() {
5035 let mut td = simple_type("default", "Person", "Person");
5036 td.properties.push(prop("rating", "int8", true));
5037 let schema = SchemaDescriptor {
5038 types: vec![td],
5039 scalars: vec![],
5040 enums: vec![],
5041 named_tuples: vec![],
5042 globals: vec![],
5043 functions: vec![],
5044 aliases: vec![],
5045 channels: vec![],
5046 ..Default::default()
5047 };
5048 let state = DbState {
5049 schemas: vec!["default".into()],
5050 tables: vec![DbTable {
5051 schema: "default".into(),
5052 name: "Person".into(),
5053 columns: vec![
5054 DbColumn {
5055 name: "id".into(),
5056 pg_type: "uuid".into(),
5057 nullable: false,
5058 is_generated: false,
5059 column_default: Some("uuidv7()".into()),
5060 },
5061 DbColumn {
5062 name: "name".into(),
5063 pg_type: "text".into(),
5064 nullable: true,
5065 is_generated: false,
5066 column_default: None,
5067 },
5068 DbColumn {
5069 name: "rating".into(),
5070 pg_type: "text".into(),
5071 nullable: true,
5072 is_generated: false,
5073 column_default: None,
5074 },
5075 ],
5076 foreign_keys: vec![],
5077 indexes: vec![],
5078 checks: vec![],
5079 triggers: vec![],
5080 }],
5081 enums: vec![],
5082 domains: vec![],
5083 ..DbState::default()
5084 };
5085
5086 let steps = diff_schema_steps(&schema, &state, &HashMap::new()).unwrap();
5087 let step = steps
5088 .iter()
5089 .find(|s| matches!(&s.op_key, OpKey::Table(m, t) if m == "default" && t == "Person"))
5090 .expect("expected an alter step for Person");
5091
5092 assert_eq!(step.required_input.len(), 1, "got: {:?}", step.required_input);
5093 let input = &step.required_input[0];
5094 assert_eq!(input.placeholder, "cast_expr__rating");
5095 assert_eq!(input.default_expr, "\"rating\"::int8");
5096 assert_eq!(input.type_name, "default::Person");
5097
5098 let placeholder_token = format!("\\({})", input.placeholder);
5099 assert!(
5100 step.ddl.iter().any(|op| op.sql.contains(&placeholder_token)),
5101 "expected the placeholder token in the step's DDL, got: {:?}",
5102 step.ddl.iter().map(|op| &op.sql).collect::<Vec<_>>()
5103 );
5104 }
5105
5106 #[test]
5107 fn test_equivalent_base_type_spelling_is_not_a_diff() {
5108 let mut td = simple_type("default", "Person", "Person");
5114 td.properties.push(prop("age", "int8", true));
5115 let schema = SchemaDescriptor {
5116 types: vec![td],
5117 scalars: vec![],
5118 enums: vec![],
5119 named_tuples: vec![],
5120 globals: vec![],
5121 functions: vec![],
5122 aliases: vec![],
5123 channels: vec![],
5124 ..Default::default()
5125 };
5126 let state = DbState {
5127 schemas: vec!["default".into()],
5128 tables: vec![DbTable {
5129 schema: "default".into(),
5130 name: "Person".into(),
5131 columns: vec![
5132 DbColumn {
5133 name: "id".into(),
5134 pg_type: "uuid".into(),
5135 nullable: false,
5136 is_generated: false,
5137 column_default: Some("uuidv7()".into()),
5138 },
5139 DbColumn {
5140 name: "name".into(),
5141 pg_type: "text".into(),
5142 nullable: true,
5143 is_generated: false,
5144 column_default: None,
5145 },
5146 DbColumn {
5147 name: "age".into(),
5148 pg_type: "bigint".into(),
5149 nullable: true,
5150 is_generated: false,
5151 column_default: None,
5152 },
5153 ],
5154 foreign_keys: vec![],
5155 indexes: vec![],
5156 checks: vec![],
5157 triggers: vec!["pylon_cache_invalidate".into()],
5158 }],
5159 enums: vec![],
5160 domains: vec![],
5161 ..DbState::default()
5162 };
5163 let ops = diff_schema(&schema, &state).unwrap();
5164 assert!(
5165 ops.iter().all(|op| !op.contains("ALTER COLUMN")),
5166 "expected no ALTER COLUMN ops, got: {:?}",
5167 ops
5168 );
5169 }
5170
5171 #[test]
5172 fn test_check_the_schema_no_longer_declares_is_dropped() {
5173 let mut person = simple_type("default", "Person", "Person");
5176 person.properties = vec![prop("id", "uuid", false)];
5177 let schema = SchemaDescriptor {
5178 types: vec![person],
5179 ..SchemaDescriptor::default()
5180 };
5181 let state = DbState {
5182 tables: vec![DbTable {
5183 schema: "default".into(),
5184 name: "Person".into(),
5185 columns: vec![DbColumn {
5186 name: "id".into(),
5187 pg_type: "uuid".into(),
5188 nullable: false,
5189 is_generated: false,
5190 column_default: None,
5191 }],
5192 foreign_keys: vec![],
5193 indexes: vec![],
5194 checks: vec![
5195 DbCheck {
5196 constraint_name: "Person_name_deadbeef_check".into(),
5197 },
5198 DbCheck {
5199 constraint_name: "a_hand_written_check".into(),
5200 },
5201 ],
5202 triggers: vec![],
5203 }],
5204 ..DbState::default()
5205 };
5206 let ops = diff_schema_ops(&schema, &state).unwrap();
5207 let joined = ops.iter().map(|op| op.sql.as_str()).collect::<Vec<_>>().join("\n");
5208 assert!(
5209 joined.contains("DROP CONSTRAINT IF EXISTS \"Person_name_deadbeef_check\""),
5210 "got:\n{joined}"
5211 );
5212 assert!(
5213 !joined.contains("a_hand_written_check"),
5214 "a constraint Pylon did not name is left alone:\n{joined}"
5215 );
5216 }
5217
5218 #[test]
5219 fn test_changed_scalar_domain_checks_are_reconciled() {
5220 use crate::schema::ScalarDescriptor;
5221 let schema = SchemaDescriptor {
5222 scalars: vec![ScalarDescriptor {
5223 name: "EmailStr".into(),
5224 module: "default".into(),
5225 base: "Str".into(),
5226 pg_type: "text".into(),
5227 check_constraints: vec!["char_length(VALUE) <= 320".into()],
5228 is_sequence: false,
5229 }],
5230 ..SchemaDescriptor::default()
5231 };
5232 let state = DbState {
5233 domains: vec![DbDomain {
5234 schema: "default".into(),
5235 name: "EmailStr".into(),
5236 checks: vec!["EmailStr_stale000_check".into()],
5237 }],
5238 ..DbState::default()
5239 };
5240 let ops = diff_schema_ops(&schema, &state).unwrap();
5241 let joined = ops.iter().map(|op| op.sql.as_str()).collect::<Vec<_>>().join("\n");
5242 assert!(joined.contains("DROP CONSTRAINT"), "the old set is cleared:\n{joined}");
5243 assert!(
5244 joined.contains("ADD CONSTRAINT") && joined.contains("char_length(VALUE) <= 320"),
5245 "the new set is added:\n{joined}"
5246 );
5247 }
5248
5249 #[test]
5250 fn test_demoting_an_interface_to_a_mixin_drops_its_view() {
5251 let mut individual = simple_type("default", "Individual", "Individual");
5254 individual.properties = vec![prop("id", "uuid", false)];
5255 let schema = SchemaDescriptor {
5256 types: vec![individual],
5257 ..SchemaDescriptor::default()
5258 };
5259 let state = DbState {
5260 tables: vec![],
5261 views: vec![DbView {
5262 schema: "public".into(),
5263 name: "Account".into(),
5264 body_hash: "whatever".into(),
5265 }],
5266 ..DbState::default()
5267 };
5268 let ops = diff_schema_ops(&schema, &state).unwrap();
5269 let joined = ops.iter().map(|op| op.sql.as_str()).collect::<Vec<_>>().join("\n");
5270 assert!(
5271 joined.contains("DROP VIEW IF EXISTS \"public\".\"Account\""),
5272 "got:\n{joined}"
5273 );
5274 }
5275
5276 #[test]
5277 fn test_registered_scalar_domain_adoption_drops_and_recreates_dependent_interface_view() {
5278 use crate::schema::ScalarDescriptor;
5284
5285 let mut account = simple_type("default", "Account", "Account");
5286 account.abstract_ = true;
5287 account.materialized = true;
5288 account.properties = vec![prop("id", "uuid", false), prop("email", "text", false)];
5289 account.properties[1].column_type = Some("\"public\".\"Email\"".into());
5290
5291 let mut individual = simple_type("default", "Individual", "Individual");
5292 individual.interfaces = vec!["default::Account".into()];
5293 individual.properties = vec![prop("id", "uuid", false), prop("email", "text", false)];
5294 individual.properties[1].column_type = Some("\"public\".\"Email\"".into());
5295
5296 let schema = SchemaDescriptor {
5297 types: vec![account, individual],
5298 scalars: vec![ScalarDescriptor {
5299 name: "Email".into(),
5300 module: "default".into(),
5301 base: "Str".into(),
5302 pg_type: "text".into(),
5303 check_constraints: vec!["value ~ '@'".into()],
5304 is_sequence: false,
5305 }],
5306 enums: vec![],
5307 named_tuples: vec![],
5308 globals: vec![],
5309 functions: vec![],
5310 aliases: vec![],
5311 channels: vec![],
5312 ..Default::default()
5313 };
5314
5315 let view_ddl = crate::export::interface_view_ddl_with_names(&schema)
5320 .into_iter()
5321 .find(|(_, n, _)| n == "Account")
5322 .unwrap()
5323 .2;
5324
5325 let state = DbState {
5326 schemas: vec!["default".into()],
5327 tables: vec![DbTable {
5328 schema: "default".into(),
5329 name: "Individual".into(),
5330 columns: vec![
5331 DbColumn {
5332 name: "id".into(),
5333 pg_type: "uuid".into(),
5334 nullable: false,
5335 is_generated: false,
5336 column_default: Some("uuidv7()".into()),
5337 },
5338 DbColumn {
5339 name: "email".into(),
5340 pg_type: "text".into(),
5341 nullable: false,
5342 is_generated: false,
5343 column_default: None,
5344 },
5345 ],
5346 foreign_keys: vec![],
5347 indexes: vec![],
5348 checks: vec![],
5349 triggers: vec!["pylon_cache_invalidate".into()],
5350 }],
5351 views: vec![DbView {
5352 schema: "default".into(),
5353 name: "Account".into(),
5354 body_hash: ddl_hash(&view_ddl),
5355 }],
5356 enums: vec![],
5357 domains: vec![],
5358 ..DbState::default()
5359 };
5360
5361 let ops = diff_schema_ops(&schema, &state).unwrap();
5365 let joined = ops.iter().map(|op| op.sql.as_str()).collect::<Vec<_>>().join("\n");
5366 let drop_pos = joined
5367 .find("DROP VIEW IF EXISTS \"public\".\"Account\"")
5368 .unwrap_or_else(|| panic!("missing DROP VIEW; got:\n{joined}"));
5369 let alter_pos = joined
5370 .find("ALTER TABLE \"public\".\"Individual\" ALTER COLUMN \"email\" TYPE \"public\".\"Email\"")
5371 .unwrap_or_else(|| panic!("missing ALTER COLUMN TYPE; got:\n{joined}"));
5372 let create_pos = joined
5373 .rfind("CREATE VIEW \"public\".\"Account\"")
5374 .unwrap_or_else(|| panic!("missing CREATE VIEW; got:\n{joined}"));
5375 assert!(drop_pos < alter_pos, "DROP VIEW must precede the ALTER; got:\n{joined}");
5376 assert!(
5377 alter_pos < create_pos,
5378 "CREATE VIEW must follow the ALTER; got:\n{joined}"
5379 );
5380 assert_eq!(
5381 joined.matches("CREATE VIEW \"public\".\"Account\"").count(),
5382 1,
5383 "view must be recreated exactly once, not duplicated by Phase 10; got:\n{joined}"
5384 );
5385 }
5386
5387 fn exclusive_email_account_schema(implementor_names: &[&str]) -> SchemaDescriptor {
5388 let mut account = simple_type("default", "Account", "Account");
5389 account.abstract_ = true;
5390 account.materialized = true;
5391 account.properties = vec![prop("id", "uuid", false), prop("email", "text", false)];
5392 account.properties[1].is_exclusive = true;
5393
5394 let mut types = vec![account];
5395 for name in implementor_names {
5396 let mut t = simple_type("default", name, name);
5397 t.interfaces = vec!["default::Account".into()];
5398 t.properties = vec![prop("id", "uuid", false), prop("email", "text", false)];
5399 t.properties[1].is_exclusive = true;
5400 types.push(t);
5401 }
5402 SchemaDescriptor {
5403 types,
5404 scalars: vec![],
5405 enums: vec![],
5406 named_tuples: vec![],
5407 globals: vec![],
5408 functions: vec![],
5409 aliases: vec![],
5410 channels: vec![],
5411 ..Default::default()
5412 }
5413 }
5414
5415 #[test]
5416 fn test_new_implementor_added_to_existing_interface_gets_exclusive_triggers_retroactively() {
5417 let schema = exclusive_email_account_schema(&["Individual", "Organization"]);
5423 let state = DbState {
5424 schemas: vec!["default".into()],
5425 tables: vec![DbTable {
5426 schema: "default".into(),
5427 name: "Individual".into(),
5428 columns: vec![
5429 DbColumn {
5430 name: "id".into(),
5431 pg_type: "uuid".into(),
5432 nullable: false,
5433 is_generated: false,
5434 column_default: Some("uuidv7()".into()),
5435 },
5436 DbColumn {
5437 name: "email".into(),
5438 pg_type: "text".into(),
5439 nullable: false,
5440 is_generated: false,
5441 column_default: None,
5442 },
5443 ],
5444 foreign_keys: vec![],
5445 indexes: vec![],
5446 checks: vec![],
5447 triggers: vec![
5448 "pylon_cache_invalidate".into(),
5449 "_excl_Account_email_ins".into(),
5450 "_excl_Account_email_upd".into(),
5451 ],
5452 }],
5453 enums: vec![],
5454 domains: vec![],
5455 ..DbState::default()
5456 };
5457
5458 let ops = diff_schema_ops(&schema, &state).unwrap();
5459 let joined = ops.iter().map(|op| op.sql.as_str()).collect::<Vec<_>>().join("\n");
5460 assert!(
5461 joined.contains(
5462 "CREATE CONSTRAINT TRIGGER \"_excl_Account_email_ins\"\nAFTER INSERT ON \"public\".\"Organization\""
5463 ),
5464 "the new implementor must get the exclusive trigger; got:\n{joined}"
5465 );
5466 assert!(
5467 !joined.contains("ON \"public\".\"Individual\""),
5468 "the already-migrated implementor's existing triggers must not be re-emitted; got:\n{joined}"
5469 );
5470 }
5471
5472 #[test]
5473 fn test_removing_exclusivity_drops_the_cross_table_triggers() {
5474 let mut schema = exclusive_email_account_schema(&["Individual"]);
5479 for t in &mut schema.types {
5480 for p in &mut t.properties {
5481 if p.name == "email" {
5482 p.is_exclusive = false;
5483 }
5484 }
5485 }
5486 let state = DbState {
5487 schemas: vec!["default".into()],
5488 tables: vec![DbTable {
5489 schema: "default".into(),
5490 name: "Individual".into(),
5491 columns: vec![
5492 DbColumn {
5493 name: "id".into(),
5494 pg_type: "uuid".into(),
5495 nullable: false,
5496 is_generated: false,
5497 column_default: Some("uuidv7()".into()),
5498 },
5499 DbColumn {
5500 name: "email".into(),
5501 pg_type: "text".into(),
5502 nullable: false,
5503 is_generated: false,
5504 column_default: None,
5505 },
5506 ],
5507 foreign_keys: vec![],
5508 indexes: vec![],
5509 checks: vec![],
5510 triggers: vec![
5511 "pylon_cache_invalidate".into(),
5512 "_excl_Account_email_ins".into(),
5513 "_excl_Account_email_upd".into(),
5514 ],
5515 }],
5516 enums: vec![],
5517 domains: vec![],
5518 ..DbState::default()
5519 };
5520
5521 let ops = diff_schema_ops(&schema, &state).unwrap();
5522 let joined = ops.iter().map(|op| op.sql.as_str()).collect::<Vec<_>>().join("\n");
5523 assert!(
5524 joined.contains("DROP TRIGGER IF EXISTS \"_excl_Account_email_ins\" ON \"public\".\"Individual\""),
5525 "got:\n{joined}"
5526 );
5527 assert!(
5528 joined.contains("DROP TRIGGER IF EXISTS \"_excl_Account_email_upd\" ON \"public\".\"Individual\""),
5529 "got:\n{joined}"
5530 );
5531 assert!(
5532 !joined.contains("DROP TRIGGER IF EXISTS \"pylon_cache_invalidate\""),
5533 "unrelated triggers must not be touched; got:\n{joined}"
5534 );
5535 }
5536
5537 #[test]
5538 fn test_new_enum() {
5539 let schema = SchemaDescriptor {
5540 types: vec![],
5541 scalars: vec![],
5542 enums: vec![EnumDescriptor {
5543 name: "Status".into(),
5544 module: "default".into(),
5545 members: vec!["Active".into(), "Inactive".into()],
5546 }],
5547 named_tuples: vec![],
5548 globals: vec![],
5549 functions: vec![],
5550 aliases: vec![],
5551 channels: vec![],
5552 ..Default::default()
5553 };
5554 let ops = diff_schema(&schema, &empty_state()).unwrap();
5555 let joined = ops.join("\n");
5556 assert!(
5557 joined.contains("CREATE TYPE \"public\".\"Status\" AS ENUM"),
5558 "got:\n{joined}"
5559 );
5560 }
5561
5562 #[test]
5563 fn test_drop_table() {
5564 let schema = SchemaDescriptor {
5565 types: vec![],
5566 scalars: vec![],
5567 enums: vec![],
5568 named_tuples: vec![],
5569 globals: vec![],
5570 functions: vec![],
5571 aliases: vec![],
5572 channels: vec![],
5573 ..Default::default()
5574 };
5575 let state = DbState {
5576 schemas: vec!["default".into()],
5577 tables: vec![DbTable {
5578 schema: "default".into(),
5579 name: "OldType".into(),
5580 columns: vec![],
5581 foreign_keys: vec![],
5582 indexes: vec![],
5583 checks: vec![],
5584 triggers: vec![],
5585 }],
5586 enums: vec![],
5587 domains: vec![],
5588 ..DbState::default()
5589 };
5590 let ops = diff_schema(&schema, &state).unwrap();
5591 let joined = ops.join("\n");
5592 assert!(
5593 joined.contains("DROP TABLE IF EXISTS \"public\".\"OldType\" CASCADE"),
5594 "got:\n{joined}"
5595 );
5596 }
5597
5598 #[test]
5599 fn test_index_on_existing_table_is_concurrently() {
5600 use crate::schema::VectorIndexDescriptor;
5601 let mut td = simple_type("default", "Post", "Post");
5602 td.vector_indexes.push(VectorIndexDescriptor {
5603 index_name: None,
5604 pointers: vec!["name".into()],
5605 model: "test".into(),
5606 metric: "cosine".into(),
5607 dimensions: 1536,
5608 });
5609 let schema = SchemaDescriptor {
5610 types: vec![td],
5611 scalars: vec![],
5612 enums: vec![],
5613 named_tuples: vec![],
5614 globals: vec![],
5615 functions: vec![],
5616 aliases: vec![],
5617 channels: vec![],
5618 ..Default::default()
5619 };
5620 let state = DbState {
5622 schemas: vec!["default".into()],
5623 tables: vec![DbTable {
5624 schema: "default".into(),
5625 name: "Post".into(),
5626 columns: vec![
5627 DbColumn {
5628 name: "id".into(),
5629 pg_type: "uuid".into(),
5630 nullable: false,
5631 is_generated: false,
5632 column_default: Some("uuidv7()".into()),
5633 },
5634 DbColumn {
5635 name: "name".into(),
5636 pg_type: "text".into(),
5637 nullable: true,
5638 is_generated: false,
5639 column_default: None,
5640 },
5641 ],
5642 foreign_keys: vec![],
5643 indexes: vec![],
5644 checks: vec![],
5645 triggers: vec![],
5646 }],
5647 enums: vec![],
5648 domains: vec![],
5649 ..DbState::default()
5650 };
5651 let ops = diff_schema_ops(&schema, &state).unwrap();
5652 let idx_op = ops.iter().find(|op| op.sql.contains("hnsw")).unwrap();
5653 assert!(
5654 idx_op.non_transactional,
5655 "index on pre-existing table should be non-transactional"
5656 );
5657 assert!(
5658 idx_op.sql.contains("CONCURRENTLY"),
5659 "should use CONCURRENTLY: {}",
5660 idx_op.sql
5661 );
5662 }
5663
5664 #[test]
5665 fn test_required_extensions_empty_without_vector_indexes() {
5666 let schema = SchemaDescriptor {
5667 types: vec![simple_type("default", "Post", "Post")],
5668 scalars: vec![],
5669 enums: vec![],
5670 named_tuples: vec![],
5671 globals: vec![],
5672 functions: vec![],
5673 aliases: vec![],
5674 channels: vec![],
5675 ..Default::default()
5676 };
5677 assert!(required_extensions(&schema).is_empty());
5678 }
5679
5680 #[test]
5681 fn test_missing_extension_ddl_when_vector_index_present_and_not_yet_installed() {
5682 use crate::schema::VectorIndexDescriptor;
5683 let mut td = simple_type("default", "Post", "Post");
5684 td.vector_indexes.push(VectorIndexDescriptor {
5685 index_name: None,
5686 pointers: vec!["name".into()],
5687 model: "test".into(),
5688 metric: "cosine".into(),
5689 dimensions: 1536,
5690 });
5691 let schema = SchemaDescriptor {
5692 types: vec![td],
5693 scalars: vec![],
5694 enums: vec![],
5695 named_tuples: vec![],
5696 globals: vec![],
5697 functions: vec![],
5698 aliases: vec![],
5699 channels: vec![],
5700 ..Default::default()
5701 };
5702 assert_eq!(required_extensions(&schema), vec!["vector"]);
5703
5704 let ddl = missing_extension_ddl(&schema, &DbState::default());
5705 assert_eq!(ddl, vec!["CREATE EXTENSION IF NOT EXISTS \"vector\";".to_string()]);
5706
5707 let already_installed = DbState {
5708 extensions: vec!["vector".into()],
5709 ..DbState::default()
5710 };
5711 assert!(missing_extension_ddl(&schema, &already_installed).is_empty());
5712 }
5713
5714 #[test]
5715 fn test_index_on_new_table_is_transactional() {
5716 use crate::schema::VectorIndexDescriptor;
5717 let mut td = simple_type("default", "Post", "Post");
5718 td.vector_indexes.push(VectorIndexDescriptor {
5719 index_name: None,
5720 pointers: vec!["name".into()],
5721 model: "test".into(),
5722 metric: "cosine".into(),
5723 dimensions: 1536,
5724 });
5725 let schema = SchemaDescriptor {
5726 types: vec![td],
5727 scalars: vec![],
5728 enums: vec![],
5729 named_tuples: vec![],
5730 globals: vec![],
5731 functions: vec![],
5732 aliases: vec![],
5733 channels: vec![],
5734 ..Default::default()
5735 };
5736 let ops = diff_schema_ops(&schema, &empty_state()).unwrap();
5738 let idx_op = ops.iter().find(|op| op.sql.contains("hnsw")).unwrap();
5739 assert!(!idx_op.non_transactional, "index on new table should be transactional");
5740 assert!(
5741 !idx_op.sql.contains("CONCURRENTLY"),
5742 "should NOT use CONCURRENTLY: {}",
5743 idx_op.sql
5744 );
5745 }
5746
5747 fn sequence_scalar(module: &str, name: &str) -> crate::schema::ScalarDescriptor {
5748 crate::schema::ScalarDescriptor {
5749 name: name.into(),
5750 module: module.into(),
5751 base: "Sequence".into(),
5752 pg_type: "int8".into(),
5753 check_constraints: vec![],
5754 is_sequence: true,
5755 }
5756 }
5757
5758 #[test]
5759 fn test_new_sequence_creates_sequence_and_domain() {
5760 let schema = SchemaDescriptor {
5761 types: vec![],
5762 enums: vec![],
5763 named_tuples: vec![],
5764 globals: vec![],
5765 functions: vec![],
5766 aliases: vec![],
5767 channels: vec![],
5768 scalars: vec![sequence_scalar("default", "OrderNumber")],
5769 ..Default::default()
5770 };
5771 let ops = diff_schema(&schema, &empty_state()).unwrap();
5772 let joined = ops.join("\n");
5773 assert!(
5774 joined.contains("CREATE SEQUENCE IF NOT EXISTS \"public\".\"OrderNumber_seq\""),
5775 "got:\n{joined}"
5776 );
5777 assert!(
5778 joined.contains("CREATE DOMAIN \"public\".\"OrderNumber\" AS int8"),
5779 "got:\n{joined}"
5780 );
5781 let seq_pos = joined.find("CREATE SEQUENCE").unwrap();
5783 let dom_pos = joined.find("CREATE DOMAIN").unwrap();
5784 assert!(seq_pos < dom_pos, "sequence must be created before domain");
5785 }
5786
5787 #[test]
5788 fn test_no_ops_sequence_already_exists() {
5789 let schema = SchemaDescriptor {
5790 types: vec![],
5791 enums: vec![],
5792 named_tuples: vec![],
5793 globals: vec![],
5794 functions: vec![],
5795 aliases: vec![],
5796 channels: vec![],
5797 scalars: vec![sequence_scalar("default", "OrderNumber")],
5798 ..Default::default()
5799 };
5800 let state = DbState {
5801 schemas: vec!["default".into()],
5802 domains: vec![DbDomain {
5803 schema: "default".into(),
5804 name: "OrderNumber".into(),
5805 checks: vec![],
5806 }],
5807 sequences: vec![DbSequence {
5808 schema: "default".into(),
5809 name: "OrderNumber_seq".into(),
5810 }],
5811 ..DbState::default()
5812 };
5813 let ops = diff_schema(&schema, &state).unwrap();
5814 assert!(
5815 ops.is_empty(),
5816 "expected no ops when sequence and domain exist, got: {:?}",
5817 ops
5818 );
5819 }
5820
5821 #[test]
5822 fn test_drop_removed_sequence() {
5823 let schema = SchemaDescriptor {
5824 types: vec![],
5825 scalars: vec![],
5826 enums: vec![],
5827 named_tuples: vec![],
5828 globals: vec![],
5829 functions: vec![],
5830 aliases: vec![],
5831 channels: vec![],
5832 ..Default::default()
5833 };
5834 let state = DbState {
5835 schemas: vec!["default".into()],
5836 domains: vec![DbDomain {
5837 schema: "default".into(),
5838 name: "OrderNumber".into(),
5839 checks: vec![],
5840 }],
5841 sequences: vec![DbSequence {
5842 schema: "default".into(),
5843 name: "OrderNumber_seq".into(),
5844 }],
5845 ..DbState::default()
5846 };
5847 let ops = diff_schema(&schema, &state).unwrap();
5848 let joined = ops.join("\n");
5849 assert!(
5850 joined.contains("DROP DOMAIN IF EXISTS \"public\".\"OrderNumber\""),
5851 "got:\n{joined}"
5852 );
5853 assert!(
5854 joined.contains("DROP SEQUENCE IF EXISTS \"public\".\"OrderNumber_seq\""),
5855 "got:\n{joined}"
5856 );
5857 }
5858
5859 #[test]
5862 fn test_diff_schema_steps_groups_multiple_column_changes_into_one_alter_step() {
5863 let mut person = simple_type("default", "Person", "Person");
5864 person.properties.push(prop("nickname", "text", true));
5865 person.properties.push(prop("age", "int8", true));
5866 let schema = SchemaDescriptor {
5867 types: vec![person],
5868 scalars: vec![],
5869 enums: vec![],
5870 named_tuples: vec![],
5871 globals: vec![],
5872 functions: vec![],
5873 aliases: vec![],
5874 channels: vec![],
5875 ..Default::default()
5876 };
5877 let state = DbState {
5878 schemas: vec!["default".into()],
5879 tables: vec![DbTable {
5880 schema: "default".into(),
5881 name: "Person".into(),
5882 columns: vec![
5883 DbColumn {
5884 name: "id".into(),
5885 pg_type: "uuid".into(),
5886 nullable: false,
5887 is_generated: false,
5888 column_default: Some("uuidv7()".into()),
5889 },
5890 DbColumn {
5891 name: "name".into(),
5892 pg_type: "text".into(),
5893 nullable: true,
5894 is_generated: false,
5895 column_default: None,
5896 },
5897 ],
5898 foreign_keys: vec![],
5899 indexes: vec![],
5900 checks: vec![],
5901 triggers: vec!["pylon_cache_invalidate".into()],
5902 }],
5903 enums: vec![],
5904 domains: vec![],
5905 ..DbState::default()
5906 };
5907
5908 let steps = diff_schema_steps(&schema, &state, &HashMap::new()).unwrap();
5909 let table_steps: Vec<&MigrationStep> = steps
5910 .iter()
5911 .filter(|s| matches!(&s.op_key, OpKey::Table(m, t) if m == "default" && t == "Person"))
5912 .collect();
5913 assert_eq!(
5914 table_steps.len(),
5915 1,
5916 "two new columns on the same table must produce one step, got: {:?}",
5917 steps.iter().map(|s| &s.prompt).collect::<Vec<_>>()
5918 );
5919 assert_eq!(table_steps[0].verb, Verb::Alter);
5920 assert_eq!(table_steps[0].prompt, "did you alter object type 'default::Person'?");
5921 assert_eq!(
5922 table_steps[0].ddl.len(),
5923 2,
5924 "expected one ADD COLUMN per new property, got: {:?}",
5925 table_steps[0].ddl.iter().map(|d| &d.sql).collect::<Vec<_>>()
5926 );
5927 }
5928
5929 #[test]
5930 fn test_diff_schema_steps_new_table_is_one_create_step_including_its_trigger() {
5931 let schema = SchemaDescriptor {
5932 types: vec![simple_type("catalog", "Product", "Product")],
5933 scalars: vec![],
5934 enums: vec![],
5935 named_tuples: vec![],
5936 globals: vec![],
5937 functions: vec![],
5938 aliases: vec![],
5939 channels: vec![],
5940 ..Default::default()
5941 };
5942 let steps = diff_schema_steps(&schema, &empty_state(), &HashMap::new()).unwrap();
5943 let table_steps: Vec<&MigrationStep> = steps
5944 .iter()
5945 .filter(|s| matches!(&s.op_key, OpKey::Table(m, t) if m == "catalog" && t == "Product"))
5946 .collect();
5947 assert_eq!(
5948 table_steps.len(),
5949 1,
5950 "got steps: {:?}",
5951 steps.iter().map(|s| &s.prompt).collect::<Vec<_>>()
5952 );
5953 assert_eq!(table_steps[0].verb, Verb::Create);
5954 assert_eq!(table_steps[0].prompt, "did you create object type 'catalog::Product'?");
5955
5956 let joined: String = table_steps[0]
5959 .ddl
5960 .iter()
5961 .map(|d| d.sql.as_str())
5962 .collect::<Vec<_>>()
5963 .join("\n");
5964 assert!(
5965 joined.contains("CREATE TABLE IF NOT EXISTS \"catalog\".\"Product\""),
5966 "got:\n{joined}"
5967 );
5968 assert!(joined.contains("pylon_cache_invalidate"), "got:\n{joined}");
5969 }
5970
5971 #[test]
5972 fn test_guidance_bans_a_rejected_type_rename_candidate() {
5973 let schema = SchemaDescriptor {
5974 types: vec![simple_type("default", "Customer", "Customer")],
5975 scalars: vec![],
5976 enums: vec![],
5977 named_tuples: vec![],
5978 globals: vec![],
5979 functions: vec![],
5980 aliases: vec![],
5981 channels: vec![],
5982 ..Default::default()
5983 };
5984 let state = DbState {
5985 schemas: vec!["default".into()],
5986 tables: vec![DbTable {
5987 schema: "default".into(),
5988 name: "Person".into(),
5989 columns: vec![
5990 DbColumn {
5991 name: "id".into(),
5992 pg_type: "uuid".into(),
5993 nullable: false,
5994 is_generated: false,
5995 column_default: Some("uuidv7()".into()),
5996 },
5997 DbColumn {
5998 name: "name".into(),
5999 pg_type: "text".into(),
6000 nullable: true,
6001 is_generated: false,
6002 column_default: None,
6003 },
6004 ],
6005 foreign_keys: vec![],
6006 indexes: vec![],
6007 checks: vec![],
6008 triggers: vec![],
6009 }],
6010 enums: vec![],
6011 domains: vec![],
6012 ..DbState::default()
6013 };
6014
6015 let candidates = detect_type_renames(&schema, &state, &Guidance::default());
6016 assert_eq!(
6017 candidates.len(),
6018 1,
6019 "expected Person -> Customer to be proposed as a rename"
6020 );
6021
6022 let mut guidance = Guidance::default();
6023 guidance.banned_type_renames.insert((
6024 "default".to_string(),
6025 "Person".to_string(),
6026 "default".to_string(),
6027 "Customer".to_string(),
6028 ));
6029 let candidates = detect_type_renames(&schema, &state, &guidance);
6030 assert!(
6031 candidates.is_empty(),
6032 "a banned rename candidate must not be re-proposed"
6033 );
6034 }
6035}