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