1use super::policy::{PolicyPermissiveness, PolicyTarget, RlsPolicy};
18use super::types::ColumnType;
19use std::collections::HashMap;
20
21#[derive(Debug, Clone, Default)]
23pub struct Schema {
24 pub tables: HashMap<String, Table>,
26 pub indexes: Vec<Index>,
28 pub migrations: Vec<MigrationHint>,
30 pub extensions: Vec<Extension>,
32 pub comments: Vec<Comment>,
34 pub sequences: Vec<Sequence>,
36 pub enums: Vec<EnumType>,
38 pub views: Vec<ViewDef>,
40 pub functions: Vec<SchemaFunctionDef>,
42 pub triggers: Vec<SchemaTriggerDef>,
44 pub grants: Vec<Grant>,
46 pub policies: Vec<RlsPolicy>,
48 pub resources: Vec<ResourceDef>,
50}
51
52#[derive(Debug, Clone, PartialEq)]
58pub enum ResourceKind {
59 Bucket,
61 Queue,
63 Topic,
65}
66
67impl std::fmt::Display for ResourceKind {
68 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69 match self {
70 Self::Bucket => write!(f, "bucket"),
71 Self::Queue => write!(f, "queue"),
72 Self::Topic => write!(f, "topic"),
73 }
74 }
75}
76
77#[derive(Debug, Clone)]
86pub struct ResourceDef {
87 pub name: String,
89 pub kind: ResourceKind,
91 pub provider: Option<String>,
93 pub properties: HashMap<String, String>,
95}
96
97#[derive(Debug, Clone)]
99pub struct Table {
100 pub name: String,
102 pub columns: Vec<Column>,
104 pub multi_column_fks: Vec<MultiColumnForeignKey>,
106 pub enable_rls: bool,
108 pub force_rls: bool,
110}
111
112#[derive(Debug, Clone)]
114pub struct Column {
115 pub name: String,
117 pub data_type: ColumnType,
119 pub nullable: bool,
121 pub primary_key: bool,
123 pub unique: bool,
125 pub default: Option<String>,
127 pub foreign_key: Option<ForeignKey>,
129 pub check: Option<CheckConstraint>,
131 pub generated: Option<Generated>,
133}
134
135#[derive(Debug, Clone)]
137pub struct ForeignKey {
138 pub table: String,
140 pub column: String,
142 pub on_delete: FkAction,
144 pub on_update: FkAction,
146 pub deferrable: Deferrable,
148}
149
150#[derive(Debug, Clone, Default, PartialEq)]
152pub enum FkAction {
153 #[default]
154 NoAction,
156 Cascade,
158 SetNull,
160 SetDefault,
162 Restrict,
164}
165
166#[derive(Debug, Clone)]
168pub struct Index {
169 pub name: String,
171 pub table: String,
173 pub columns: Vec<String>,
175 pub unique: bool,
177 pub method: IndexMethod,
179 pub where_clause: Option<CheckExpr>,
181 pub include: Vec<String>,
183 pub concurrently: bool,
185 pub expressions: Vec<String>,
187}
188
189#[derive(Debug, Clone)]
191pub enum MigrationHint {
192 Rename {
194 from: String,
196 to: String,
198 },
199 Transform {
201 expression: String,
203 target: String,
205 },
206 Drop {
208 target: String,
210 confirmed: bool,
212 },
213}
214
215#[derive(Debug, Clone, Copy, PartialEq, Eq)]
221pub enum CheckComparisonOp {
222 Equal,
224 NotEqual,
226 GreaterThan,
228 GreaterOrEqual,
230 LessThan,
232 LessOrEqual,
234}
235
236impl CheckComparisonOp {
237 pub fn as_sql_str(self) -> &'static str {
239 match self {
240 CheckComparisonOp::Equal => "=",
241 CheckComparisonOp::NotEqual => "<>",
242 CheckComparisonOp::GreaterThan => ">",
243 CheckComparisonOp::GreaterOrEqual => ">=",
244 CheckComparisonOp::LessThan => "<",
245 CheckComparisonOp::LessOrEqual => "<=",
246 }
247 }
248}
249
250#[derive(Debug, Clone)]
252pub enum CheckExpr {
253 GreaterThan {
255 column: String,
257 value: i64,
259 },
260 GreaterOrEqual {
262 column: String,
264 value: i64,
266 },
267 LessThan {
269 column: String,
271 value: i64,
273 },
274 LessOrEqual {
276 column: String,
278 value: i64,
280 },
281 Between {
283 column: String,
285 low: i64,
287 high: i64,
289 },
290 In {
292 column: String,
294 values: Vec<String>,
296 },
297 InIntegers {
299 column: String,
301 values: Vec<i64>,
303 },
304 CompareColumns {
306 left_column: String,
308 op: CheckComparisonOp,
310 right_column: String,
312 },
313 TextCompare {
315 column: String,
317 op: CheckComparisonOp,
319 value: String,
321 },
322 CompareColumnToCoalesce {
324 left_column: String,
326 op: CheckComparisonOp,
328 coalesce_column: String,
330 fallback: String,
332 fallback_cast: Option<String>,
334 },
335 LowerTrimEquals {
337 column: String,
339 },
340 Regex {
342 column: String,
344 pattern: String,
346 },
347 MaxLength {
349 column: String,
351 max: usize,
353 },
354 MinLength {
356 column: String,
358 min: usize,
360 },
361 NotNull {
363 column: String,
365 },
366 And(Box<CheckExpr>, Box<CheckExpr>),
368 Or(Box<CheckExpr>, Box<CheckExpr>),
370 Not(Box<CheckExpr>),
372 Sql(String),
374}
375
376#[derive(Debug, Clone)]
378pub struct CheckConstraint {
379 pub expr: CheckExpr,
381 pub name: Option<String>,
383}
384
385#[derive(Debug, Clone, Default, PartialEq)]
391pub enum Deferrable {
392 #[default]
393 NotDeferrable,
395 Deferrable,
397 InitiallyDeferred,
399 InitiallyImmediate,
401}
402
403#[derive(Debug, Clone)]
409pub enum Generated {
410 AlwaysStored(String),
412 AlwaysIdentity,
414 ByDefaultIdentity,
416}
417
418#[derive(Debug, Clone, Default, PartialEq)]
424pub enum IndexMethod {
425 #[default]
426 BTree,
428 Hash,
430 Gin,
432 Gist,
434 Brin,
436 SpGist,
438 Hnsw,
440 IvfFlat,
442}
443
444pub(crate) fn index_method_str(method: &IndexMethod) -> &'static str {
445 match method {
446 IndexMethod::BTree => "btree",
447 IndexMethod::Hash => "hash",
448 IndexMethod::Gin => "gin",
449 IndexMethod::Gist => "gist",
450 IndexMethod::Brin => "brin",
451 IndexMethod::SpGist => "spgist",
452 IndexMethod::Hnsw => "hnsw",
453 IndexMethod::IvfFlat => "ivfflat",
454 }
455}
456
457#[derive(Debug, Clone, PartialEq)]
463pub struct Extension {
464 pub name: String,
466 pub schema: Option<String>,
468 pub version: Option<String>,
470}
471
472impl Extension {
473 pub fn new(name: impl Into<String>) -> Self {
475 Self {
476 name: name.into(),
477 schema: None,
478 version: None,
479 }
480 }
481
482 pub fn schema(mut self, schema: impl Into<String>) -> Self {
484 self.schema = Some(schema.into());
485 self
486 }
487
488 pub fn version(mut self, version: impl Into<String>) -> Self {
490 self.version = Some(version.into());
491 self
492 }
493}
494
495#[derive(Debug, Clone, PartialEq)]
497pub struct Comment {
498 pub target: CommentTarget,
500 pub text: String,
502}
503
504#[derive(Debug, Clone, PartialEq)]
506pub enum CommentTarget {
507 Table(String),
509 Column {
511 table: String,
513 column: String,
515 },
516 Raw(String),
518}
519
520impl Comment {
521 pub fn on_table(table: impl Into<String>, text: impl Into<String>) -> Self {
523 Self {
524 target: CommentTarget::Table(table.into()),
525 text: text.into(),
526 }
527 }
528
529 pub fn on_column(
531 table: impl Into<String>,
532 column: impl Into<String>,
533 text: impl Into<String>,
534 ) -> Self {
535 Self {
536 target: CommentTarget::Column {
537 table: table.into(),
538 column: column.into(),
539 },
540 text: text.into(),
541 }
542 }
543
544 pub fn on_raw(target: impl Into<String>, text: impl Into<String>) -> Self {
546 Self {
547 target: CommentTarget::Raw(target.into()),
548 text: text.into(),
549 }
550 }
551}
552
553#[derive(Debug, Clone, PartialEq)]
555pub struct Sequence {
556 pub name: String,
558 pub data_type: Option<String>,
560 pub start: Option<i64>,
562 pub increment: Option<i64>,
564 pub min_value: Option<i64>,
566 pub max_value: Option<i64>,
568 pub cache: Option<i64>,
570 pub cycle: bool,
572 pub owned_by: Option<String>,
574}
575
576impl Sequence {
577 pub fn new(name: impl Into<String>) -> Self {
579 Self {
580 name: name.into(),
581 data_type: None,
582 start: None,
583 increment: None,
584 min_value: None,
585 max_value: None,
586 cache: None,
587 cycle: false,
588 owned_by: None,
589 }
590 }
591
592 pub fn start(mut self, v: i64) -> Self {
594 self.start = Some(v);
595 self
596 }
597
598 pub fn increment(mut self, v: i64) -> Self {
600 self.increment = Some(v);
601 self
602 }
603
604 pub fn min_value(mut self, v: i64) -> Self {
606 self.min_value = Some(v);
607 self
608 }
609
610 pub fn max_value(mut self, v: i64) -> Self {
612 self.max_value = Some(v);
613 self
614 }
615
616 pub fn cache(mut self, v: i64) -> Self {
618 self.cache = Some(v);
619 self
620 }
621
622 pub fn cycle(mut self) -> Self {
624 self.cycle = true;
625 self
626 }
627
628 pub fn owned_by(mut self, col: impl Into<String>) -> Self {
630 self.owned_by = Some(col.into());
631 self
632 }
633}
634
635#[derive(Debug, Clone, PartialEq)]
641pub struct EnumType {
642 pub name: String,
644 pub values: Vec<String>,
646}
647
648impl EnumType {
649 pub fn new(name: impl Into<String>, values: Vec<String>) -> Self {
651 Self {
652 name: name.into(),
653 values,
654 }
655 }
656
657 pub fn add_value(mut self, value: impl Into<String>) -> Self {
659 self.values.push(value.into());
660 self
661 }
662}
663
664#[derive(Debug, Clone, PartialEq)]
666pub struct MultiColumnForeignKey {
667 pub columns: Vec<String>,
669 pub ref_table: String,
671 pub ref_columns: Vec<String>,
673 pub on_delete: FkAction,
675 pub on_update: FkAction,
677 pub deferrable: Deferrable,
679 pub name: Option<String>,
681}
682
683impl MultiColumnForeignKey {
684 pub fn new(
686 columns: Vec<String>,
687 ref_table: impl Into<String>,
688 ref_columns: Vec<String>,
689 ) -> Self {
690 Self {
691 columns,
692 ref_table: ref_table.into(),
693 ref_columns,
694 on_delete: FkAction::default(),
695 on_update: FkAction::default(),
696 deferrable: Deferrable::default(),
697 name: None,
698 }
699 }
700
701 pub fn on_delete(mut self, action: FkAction) -> Self {
703 self.on_delete = action;
704 self
705 }
706
707 pub fn on_update(mut self, action: FkAction) -> Self {
709 self.on_update = action;
710 self
711 }
712
713 pub fn named(mut self, name: impl Into<String>) -> Self {
715 self.name = Some(name.into());
716 self
717 }
718
719 pub fn deferrable(mut self) -> Self {
721 self.deferrable = Deferrable::Deferrable;
722 self
723 }
724
725 pub fn initially_deferred(mut self) -> Self {
727 self.deferrable = Deferrable::InitiallyDeferred;
728 self
729 }
730
731 pub fn initially_immediate(mut self) -> Self {
733 self.deferrable = Deferrable::InitiallyImmediate;
734 self
735 }
736}
737
738#[derive(Debug, Clone, PartialEq)]
744pub struct ViewDef {
745 pub name: String,
747 pub query: String,
749 pub materialized: bool,
751}
752
753impl ViewDef {
754 pub fn new(name: impl Into<String>, query: impl Into<String>) -> Self {
756 Self {
757 name: name.into(),
758 query: query.into(),
759 materialized: false,
760 }
761 }
762
763 pub fn materialized(mut self) -> Self {
765 self.materialized = true;
766 self
767 }
768}
769
770#[derive(Debug, Clone, PartialEq)]
772pub struct SchemaFunctionDef {
773 pub name: String,
775 pub args: Vec<String>,
777 pub returns: String,
779 pub body: String,
781 pub language: String,
783 pub volatility: Option<String>,
785}
786
787impl SchemaFunctionDef {
788 pub fn new(
790 name: impl Into<String>,
791 returns: impl Into<String>,
792 body: impl Into<String>,
793 ) -> Self {
794 Self {
795 name: name.into(),
796 args: Vec::new(),
797 returns: returns.into(),
798 body: body.into(),
799 language: "plpgsql".to_string(),
800 volatility: None,
801 }
802 }
803
804 pub fn language(mut self, lang: impl Into<String>) -> Self {
806 self.language = lang.into();
807 self
808 }
809
810 pub fn arg(mut self, arg: impl Into<String>) -> Self {
812 self.args.push(arg.into());
813 self
814 }
815
816 pub fn volatility(mut self, v: impl Into<String>) -> Self {
818 self.volatility = Some(v.into());
819 self
820 }
821}
822
823#[derive(Debug, Clone, PartialEq)]
825pub struct SchemaTriggerDef {
826 pub name: String,
828 pub table: String,
830 pub timing: String,
832 pub events: Vec<String>,
834 pub update_columns: Vec<String>,
836 pub for_each_row: bool,
838 pub execute_function: String,
840 pub condition: Option<String>,
842}
843
844impl SchemaTriggerDef {
845 pub fn new(
847 name: impl Into<String>,
848 table: impl Into<String>,
849 execute_function: impl Into<String>,
850 ) -> Self {
851 Self {
852 name: name.into(),
853 table: table.into(),
854 timing: "BEFORE".to_string(),
855 events: vec!["INSERT".to_string()],
856 update_columns: Vec::new(),
857 for_each_row: true,
858 execute_function: execute_function.into(),
859 condition: None,
860 }
861 }
862
863 pub fn timing(mut self, t: impl Into<String>) -> Self {
865 self.timing = t.into();
866 self
867 }
868
869 pub fn events(mut self, evts: Vec<String>) -> Self {
871 self.events = evts;
872 self
873 }
874
875 pub fn for_each_statement(mut self) -> Self {
877 self.for_each_row = false;
878 self
879 }
880
881 pub fn condition(mut self, cond: impl Into<String>) -> Self {
883 self.condition = Some(cond.into());
884 self
885 }
886}
887
888#[derive(Debug, Clone, PartialEq)]
890pub struct Grant {
891 pub action: GrantAction,
893 pub privileges: Vec<Privilege>,
895 pub on_object: String,
897 pub to_role: String,
899}
900
901#[derive(Debug, Clone, PartialEq, Default)]
903pub enum GrantAction {
904 #[default]
905 Grant,
907 Revoke,
909}
910
911#[derive(Debug, Clone, PartialEq)]
913pub enum Privilege {
914 All,
916 Select,
918 Insert,
920 Update,
922 Delete,
924 Usage,
926 Execute,
928}
929
930impl std::fmt::Display for Privilege {
931 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
932 match self {
933 Privilege::All => write!(f, "ALL"),
934 Privilege::Select => write!(f, "SELECT"),
935 Privilege::Insert => write!(f, "INSERT"),
936 Privilege::Update => write!(f, "UPDATE"),
937 Privilege::Delete => write!(f, "DELETE"),
938 Privilege::Usage => write!(f, "USAGE"),
939 Privilege::Execute => write!(f, "EXECUTE"),
940 }
941 }
942}
943
944impl Grant {
945 pub fn new(
947 privileges: Vec<Privilege>,
948 on_object: impl Into<String>,
949 to_role: impl Into<String>,
950 ) -> Self {
951 Self {
952 action: GrantAction::Grant,
953 privileges,
954 on_object: on_object.into(),
955 to_role: to_role.into(),
956 }
957 }
958
959 pub fn revoke(
961 privileges: Vec<Privilege>,
962 on_object: impl Into<String>,
963 from_role: impl Into<String>,
964 ) -> Self {
965 Self {
966 action: GrantAction::Revoke,
967 privileges,
968 on_object: on_object.into(),
969 to_role: from_role.into(),
970 }
971 }
972}
973
974impl Schema {
975 pub fn new() -> Self {
977 Self::default()
978 }
979
980 pub fn add_table(&mut self, table: Table) {
982 self.tables.insert(table.name.clone(), table);
983 }
984
985 pub fn add_index(&mut self, index: Index) {
987 self.indexes.push(index);
988 }
989
990 pub fn add_hint(&mut self, hint: MigrationHint) {
992 self.migrations.push(hint);
993 }
994
995 pub fn add_extension(&mut self, ext: Extension) {
997 self.extensions.push(ext);
998 }
999
1000 pub fn add_comment(&mut self, comment: Comment) {
1002 self.comments.push(comment);
1003 }
1004
1005 pub fn add_sequence(&mut self, seq: Sequence) {
1007 self.sequences.push(seq);
1008 }
1009
1010 pub fn add_enum(&mut self, enum_type: EnumType) {
1012 self.enums.push(enum_type);
1013 }
1014
1015 pub fn add_view(&mut self, view: ViewDef) {
1017 self.views.push(view);
1018 }
1019
1020 pub fn add_function(&mut self, func: SchemaFunctionDef) {
1022 self.functions.push(func);
1023 }
1024
1025 pub fn add_trigger(&mut self, trigger: SchemaTriggerDef) {
1027 self.triggers.push(trigger);
1028 }
1029
1030 pub fn add_grant(&mut self, grant: Grant) {
1032 self.grants.push(grant);
1033 }
1034
1035 pub fn add_resource(&mut self, resource: ResourceDef) {
1037 self.resources.push(resource);
1038 }
1039
1040 pub fn add_policy(&mut self, policy: RlsPolicy) {
1042 self.policies.push(policy);
1043 }
1044
1045 pub fn validate(&self) -> Result<(), Vec<String>> {
1047 let mut errors = Vec::new();
1048
1049 for table in self.tables.values() {
1050 let mut seen_columns = std::collections::BTreeSet::new();
1051 for col in &table.columns {
1052 if !seen_columns.insert(col.name.as_str()) {
1053 errors.push(format!(
1054 "Schema error: table '{}' has duplicate column '{}'",
1055 table.name, col.name
1056 ));
1057 }
1058 }
1059
1060 let table_columns = table
1061 .columns
1062 .iter()
1063 .map(|column| column.name.as_str())
1064 .collect::<std::collections::BTreeSet<_>>();
1065 let mut seen_constraint_names = std::collections::BTreeSet::new();
1066
1067 for col in &table.columns {
1068 if col.primary_key && !col.data_type.can_be_primary_key() {
1069 errors.push(format!(
1070 "Schema error: {}.{} of type {} cannot be a primary key",
1071 table.name,
1072 col.name,
1073 col.data_type.name()
1074 ));
1075 }
1076 if col.unique && !col.data_type.supports_indexing() {
1077 errors.push(format!(
1078 "Schema error: {}.{} of type {} cannot have UNIQUE constraint",
1079 table.name,
1080 col.name,
1081 col.data_type.name()
1082 ));
1083 }
1084
1085 if let Some(check) = &col.check
1086 && let Some(name) = &check.name
1087 {
1088 if name.trim().is_empty() {
1089 errors.push(format!(
1090 "Constraint error: {}.{} has empty CHECK constraint name",
1091 table.name, col.name
1092 ));
1093 } else if !seen_constraint_names.insert(name.as_str()) {
1094 errors.push(format!(
1095 "Constraint error: table '{}' has duplicate constraint name '{}'",
1096 table.name, name
1097 ));
1098 }
1099 }
1100
1101 if let Some(ref fk) = col.foreign_key {
1102 if !self.tables.contains_key(&fk.table) {
1103 errors.push(format!(
1104 "FK error: {}.{} references non-existent table '{}'",
1105 table.name, col.name, fk.table
1106 ));
1107 } else {
1108 let ref_table = &self.tables[&fk.table];
1109 if !ref_table.columns.iter().any(|c| c.name == fk.column) {
1110 errors.push(format!(
1111 "FK error: {}.{} references non-existent column '{}.{}'",
1112 table.name, col.name, fk.table, fk.column
1113 ));
1114 } else if !schema_has_unique_key(
1115 self,
1116 &fk.table,
1117 std::slice::from_ref(&fk.column),
1118 ) {
1119 errors.push(format!(
1120 "FK error: {}.{} references '{}.{}' without a UNIQUE or PRIMARY KEY constraint",
1121 table.name, col.name, fk.table, fk.column
1122 ));
1123 }
1124 }
1125 }
1126
1127 if let Some(check) = &col.check {
1128 for referenced in check_expr_column_references(&check.expr) {
1129 let referenced_column = check_expr_reference_name(referenced);
1130 if !table_columns.contains(referenced_column.as_str()) {
1131 errors.push(format!(
1132 "CHECK error: {}.{} references non-existent column '{}.{}'",
1133 table.name, col.name, table.name, referenced_column
1134 ));
1135 }
1136 }
1137 }
1138 }
1139
1140 for fk in &table.multi_column_fks {
1141 if let Some(name) = &fk.name {
1142 if name.trim().is_empty() {
1143 errors.push(format!(
1144 "Multi-column FK error: {} has empty constraint name",
1145 table.name
1146 ));
1147 } else if !seen_constraint_names.insert(name.as_str()) {
1148 errors.push(format!(
1149 "Constraint error: table '{}' has duplicate constraint name '{}'",
1150 table.name, name
1151 ));
1152 }
1153 }
1154
1155 if fk.columns.is_empty() {
1156 errors.push(format!(
1157 "Multi-column FK error: {} has no source columns",
1158 table.name
1159 ));
1160 }
1161 if fk.ref_columns.is_empty() {
1162 errors.push(format!(
1163 "Multi-column FK error: {} references '{}' with no target columns",
1164 table.name, fk.ref_table
1165 ));
1166 }
1167 if fk.columns.len() != fk.ref_columns.len() {
1168 errors.push(format!(
1169 "Multi-column FK error: {} column count {} does not match referenced column count {}",
1170 table.name,
1171 fk.columns.len(),
1172 fk.ref_columns.len()
1173 ));
1174 }
1175
1176 for source_col in &fk.columns {
1177 if !table.columns.iter().any(|c| c.name == *source_col) {
1178 errors.push(format!(
1179 "Multi-column FK error: {} references non-existent source column '{}.{}'",
1180 table.name, table.name, source_col
1181 ));
1182 }
1183 }
1184
1185 let Some(ref_table) = self.tables.get(&fk.ref_table) else {
1186 errors.push(format!(
1187 "Multi-column FK error: {} references non-existent table '{}'",
1188 table.name, fk.ref_table
1189 ));
1190 continue;
1191 };
1192
1193 let mut all_ref_columns_exist = true;
1194 for ref_col in &fk.ref_columns {
1195 if !ref_table.columns.iter().any(|c| c.name == *ref_col) {
1196 all_ref_columns_exist = false;
1197 errors.push(format!(
1198 "Multi-column FK error: {} references non-existent column '{}.{}'",
1199 table.name, fk.ref_table, ref_col
1200 ));
1201 }
1202 }
1203
1204 if all_ref_columns_exist
1205 && !fk.ref_columns.is_empty()
1206 && fk.columns.len() == fk.ref_columns.len()
1207 && !schema_has_unique_key(self, &fk.ref_table, &fk.ref_columns)
1208 {
1209 errors.push(format!(
1210 "Multi-column FK error: {} references '{}({})' without a matching UNIQUE or PRIMARY KEY constraint",
1211 table.name,
1212 fk.ref_table,
1213 fk.ref_columns.join(", ")
1214 ));
1215 }
1216 }
1217 }
1218
1219 let mut seen_index_names = std::collections::BTreeSet::new();
1220 for index in &self.indexes {
1221 if !seen_index_names.insert(index.name.as_str()) {
1222 errors.push(format!(
1223 "Index error: duplicate index name '{}'",
1224 index.name
1225 ));
1226 }
1227
1228 let Some(table) = self.tables.get(&index.table) else {
1229 errors.push(format!(
1230 "Index error: {} references non-existent table '{}'",
1231 index.name, index.table
1232 ));
1233 continue;
1234 };
1235
1236 if index.columns.is_empty() && index.expressions.is_empty() {
1237 errors.push(format!(
1238 "Index error: {} must define at least one column or expression",
1239 index.name
1240 ));
1241 }
1242 if !index.columns.is_empty() && !index.expressions.is_empty() {
1243 errors.push(format!(
1244 "Index error: {} cannot mix columns and expressions",
1245 index.name
1246 ));
1247 }
1248
1249 for column in &index.columns {
1250 if column.trim().is_empty() {
1251 errors.push(format!("Index error: {} has empty column", index.name));
1252 continue;
1253 }
1254 let Some(column_name) = index_column_reference_name(column) else {
1255 continue;
1256 };
1257 if !table.columns.iter().any(|c| c.name == column_name) {
1258 errors.push(format!(
1259 "Index error: {} references non-existent column '{}.{}'",
1260 index.name, index.table, column_name
1261 ));
1262 }
1263 }
1264
1265 for expression in &index.expressions {
1266 if expression.trim().is_empty() {
1267 errors.push(format!("Index error: {} has empty expression", index.name));
1268 }
1269 }
1270
1271 for include_column in &index.include {
1272 let Some(column_name) = index_column_reference_name(include_column) else {
1273 errors.push(format!(
1274 "Index error: {} has invalid INCLUDE column '{}'",
1275 index.name, include_column
1276 ));
1277 continue;
1278 };
1279 if !table.columns.iter().any(|c| c.name == column_name) {
1280 errors.push(format!(
1281 "Index error: {} references non-existent INCLUDE column '{}.{}'",
1282 index.name, index.table, column_name
1283 ));
1284 }
1285 }
1286
1287 if let Some(where_clause) = &index.where_clause {
1288 for referenced in check_expr_column_references(where_clause) {
1289 let referenced_column = check_expr_reference_name(referenced);
1290 if !table.columns.iter().any(|c| c.name == referenced_column) {
1291 errors.push(format!(
1292 "Index error: {} WHERE references non-existent column '{}.{}'",
1293 index.name, index.table, referenced_column
1294 ));
1295 }
1296 }
1297 }
1298 }
1299
1300 if errors.is_empty() {
1301 Ok(())
1302 } else {
1303 Err(errors)
1304 }
1305 }
1306}
1307
1308fn check_expr_column_references(expr: &CheckExpr) -> Vec<&str> {
1309 let mut refs = Vec::new();
1310 collect_check_expr_column_references(expr, &mut refs);
1311 refs.sort_unstable();
1312 refs.dedup();
1313 refs
1314}
1315
1316fn collect_check_expr_column_references<'a>(expr: &'a CheckExpr, refs: &mut Vec<&'a str>) {
1317 match expr {
1318 CheckExpr::GreaterThan { column, .. }
1319 | CheckExpr::GreaterOrEqual { column, .. }
1320 | CheckExpr::LessThan { column, .. }
1321 | CheckExpr::LessOrEqual { column, .. }
1322 | CheckExpr::Between { column, .. }
1323 | CheckExpr::In { column, .. }
1324 | CheckExpr::InIntegers { column, .. }
1325 | CheckExpr::TextCompare { column, .. }
1326 | CheckExpr::LowerTrimEquals { column }
1327 | CheckExpr::Regex { column, .. }
1328 | CheckExpr::MaxLength { column, .. }
1329 | CheckExpr::MinLength { column, .. }
1330 | CheckExpr::NotNull { column } => refs.push(column),
1331 CheckExpr::CompareColumns {
1332 left_column,
1333 right_column,
1334 ..
1335 } => {
1336 refs.push(left_column);
1337 refs.push(right_column);
1338 }
1339 CheckExpr::CompareColumnToCoalesce {
1340 left_column,
1341 coalesce_column,
1342 ..
1343 } => {
1344 refs.push(left_column);
1345 refs.push(coalesce_column);
1346 }
1347 CheckExpr::And(left, right) | CheckExpr::Or(left, right) => {
1348 collect_check_expr_column_references(left, refs);
1349 collect_check_expr_column_references(right, refs);
1350 }
1351 CheckExpr::Not(inner) => collect_check_expr_column_references(inner, refs),
1352 CheckExpr::Sql(_) => {}
1353 }
1354}
1355
1356fn check_expr_reference_name(reference: &str) -> String {
1357 let trimmed = reference.trim();
1358 let unqualified = trimmed.rsplit('.').next().unwrap_or(trimmed);
1359 unquote_identifier(unqualified)
1360}
1361
1362fn schema_has_unique_key(schema: &Schema, table_name: &str, columns: &[String]) -> bool {
1363 if columns.is_empty() {
1364 return false;
1365 }
1366
1367 let Some(table) = schema.tables.get(table_name) else {
1368 return false;
1369 };
1370
1371 if columns.len() == 1
1372 && table
1373 .columns
1374 .iter()
1375 .any(|column| column.name == columns[0] && (column.primary_key || column.unique))
1376 {
1377 return true;
1378 }
1379
1380 schema.indexes.iter().any(|index| {
1381 index.table == table_name
1382 && index.unique
1383 && index.where_clause.is_none()
1384 && index.expressions.is_empty()
1385 && index.columns.len() == columns.len()
1386 && index
1387 .columns
1388 .iter()
1389 .filter_map(|column| index_column_reference_name(column))
1390 .eq(columns.iter().cloned())
1391 })
1392}
1393
1394fn index_column_reference_name(fragment: &str) -> Option<String> {
1395 let fragment = fragment.trim();
1396 if fragment.is_empty() || fragment.contains('(') || fragment.contains("->") {
1397 return None;
1398 }
1399
1400 let token = first_index_column_token(fragment)?;
1401 let unqualified = token.rsplit('.').next().unwrap_or(token);
1402 Some(unquote_identifier(unqualified))
1403}
1404
1405fn first_index_column_token(fragment: &str) -> Option<&str> {
1406 let fragment = fragment.trim_start();
1407 if fragment.starts_with('"') {
1408 let mut escaped = false;
1409 for (idx, ch) in fragment.char_indices().skip(1) {
1410 if escaped {
1411 escaped = false;
1412 continue;
1413 }
1414 if ch == '"' {
1415 if fragment[idx + ch.len_utf8()..].starts_with('"') {
1416 escaped = true;
1417 continue;
1418 }
1419 return Some(&fragment[..=idx]);
1420 }
1421 }
1422 return None;
1423 }
1424
1425 let end = fragment
1426 .find(|ch: char| ch.is_whitespace() || ch == '-' || ch == '>')
1427 .unwrap_or(fragment.len());
1428 (end > 0).then_some(&fragment[..end])
1429}
1430
1431fn unquote_identifier(identifier: &str) -> String {
1432 identifier
1433 .strip_prefix('"')
1434 .and_then(|s| s.strip_suffix('"'))
1435 .map(|s| s.replace("\"\"", "\""))
1436 .unwrap_or_else(|| identifier.to_string())
1437}
1438
1439impl Table {
1440 pub fn new(name: impl Into<String>) -> Self {
1442 Self {
1443 name: name.into(),
1444 columns: Vec::new(),
1445 multi_column_fks: Vec::new(),
1446 enable_rls: false,
1447 force_rls: false,
1448 }
1449 }
1450
1451 pub fn column(mut self, col: Column) -> Self {
1453 self.columns.push(col);
1454 self
1455 }
1456
1457 pub fn foreign_key(mut self, fk: MultiColumnForeignKey) -> Self {
1459 self.multi_column_fks.push(fk);
1460 self
1461 }
1462}
1463
1464impl Column {
1465 fn primary_key_type_error(&self) -> String {
1466 format!(
1467 "Column '{}' of type {} cannot be a primary key. \
1468 Valid PK types: scalar/indexable types \
1469 (UUID, TEXT, VARCHAR, INT, BIGINT, SERIAL, BIGSERIAL, BOOLEAN, FLOAT, DECIMAL, \
1470 TIMESTAMP, TIMESTAMPTZ, DATE, TIME, ENUM, INET, CIDR, MACADDR)",
1471 self.name,
1472 self.data_type.name()
1473 )
1474 }
1475
1476 fn unique_type_error(&self) -> String {
1477 format!(
1478 "Column '{}' of type {} cannot have UNIQUE constraint. \
1479 JSONB and BYTEA types do not support standard indexing.",
1480 self.name,
1481 self.data_type.name()
1482 )
1483 }
1484
1485 pub fn new(name: impl Into<String>, data_type: ColumnType) -> Self {
1487 Self {
1488 name: name.into(),
1489 data_type,
1490 nullable: true,
1491 primary_key: false,
1492 unique: false,
1493 default: None,
1494 foreign_key: None,
1495 check: None,
1496 generated: None,
1497 }
1498 }
1499
1500 pub fn not_null(mut self) -> Self {
1502 self.nullable = false;
1503 self
1504 }
1505
1506 pub fn primary_key(mut self) -> Self {
1513 if !self.data_type.can_be_primary_key() {
1514 #[cfg(debug_assertions)]
1515 eprintln!("QAIL: {}", self.primary_key_type_error());
1516 }
1517 self.primary_key = true;
1518 self.nullable = false;
1519 self
1520 }
1521
1522 pub fn try_primary_key(mut self) -> Result<Self, String> {
1526 if !self.data_type.can_be_primary_key() {
1527 return Err(self.primary_key_type_error());
1528 }
1529 self.primary_key = true;
1530 self.nullable = false;
1531 Ok(self)
1532 }
1533
1534 pub fn unique(mut self) -> Self {
1541 if !self.data_type.supports_indexing() {
1542 #[cfg(debug_assertions)]
1543 eprintln!("QAIL: {}", self.unique_type_error());
1544 }
1545 self.unique = true;
1546 self
1547 }
1548
1549 pub fn try_unique(mut self) -> Result<Self, String> {
1553 if !self.data_type.supports_indexing() {
1554 return Err(self.unique_type_error());
1555 }
1556 self.unique = true;
1557 Ok(self)
1558 }
1559
1560 pub fn default(mut self, val: impl Into<String>) -> Self {
1562 self.default = Some(val.into());
1563 self
1564 }
1565
1566 pub fn references(mut self, table: &str, column: &str) -> Self {
1574 self.foreign_key = Some(ForeignKey {
1575 table: table.to_string(),
1576 column: column.to_string(),
1577 on_delete: FkAction::default(),
1578 on_update: FkAction::default(),
1579 deferrable: Deferrable::default(),
1580 });
1581 self
1582 }
1583
1584 pub fn on_delete(mut self, action: FkAction) -> Self {
1586 if let Some(ref mut fk) = self.foreign_key {
1587 fk.on_delete = action;
1588 }
1589 self
1590 }
1591
1592 pub fn on_update(mut self, action: FkAction) -> Self {
1594 if let Some(ref mut fk) = self.foreign_key {
1595 fk.on_update = action;
1596 }
1597 self
1598 }
1599
1600 pub fn check(mut self, expr: CheckExpr) -> Self {
1604 self.check = Some(CheckConstraint { expr, name: None });
1605 self
1606 }
1607
1608 pub fn check_named(mut self, name: impl Into<String>, expr: CheckExpr) -> Self {
1610 self.check = Some(CheckConstraint {
1611 expr,
1612 name: Some(name.into()),
1613 });
1614 self
1615 }
1616
1617 pub fn deferrable(mut self) -> Self {
1621 if let Some(ref mut fk) = self.foreign_key {
1622 fk.deferrable = Deferrable::Deferrable;
1623 }
1624 self
1625 }
1626
1627 pub fn initially_deferred(mut self) -> Self {
1629 if let Some(ref mut fk) = self.foreign_key {
1630 fk.deferrable = Deferrable::InitiallyDeferred;
1631 }
1632 self
1633 }
1634
1635 pub fn initially_immediate(mut self) -> Self {
1637 if let Some(ref mut fk) = self.foreign_key {
1638 fk.deferrable = Deferrable::InitiallyImmediate;
1639 }
1640 self
1641 }
1642
1643 pub fn generated_stored(mut self, expr: impl Into<String>) -> Self {
1647 self.generated = Some(Generated::AlwaysStored(expr.into()));
1648 self
1649 }
1650
1651 pub fn generated_identity(mut self) -> Self {
1653 self.generated = Some(Generated::AlwaysIdentity);
1654 self
1655 }
1656
1657 pub fn generated_by_default(mut self) -> Self {
1659 self.generated = Some(Generated::ByDefaultIdentity);
1660 self
1661 }
1662}
1663
1664impl Index {
1665 pub fn new(name: impl Into<String>, table: impl Into<String>, columns: Vec<String>) -> Self {
1667 Self {
1668 name: name.into(),
1669 table: table.into(),
1670 columns,
1671 unique: false,
1672 method: IndexMethod::default(),
1673 where_clause: None,
1674 include: Vec::new(),
1675 concurrently: false,
1676 expressions: Vec::new(),
1677 }
1678 }
1679
1680 pub fn expression(
1682 name: impl Into<String>,
1683 table: impl Into<String>,
1684 expressions: Vec<String>,
1685 ) -> Self {
1686 Self {
1687 name: name.into(),
1688 table: table.into(),
1689 columns: Vec::new(),
1690 unique: false,
1691 method: IndexMethod::default(),
1692 where_clause: None,
1693 include: Vec::new(),
1694 concurrently: false,
1695 expressions,
1696 }
1697 }
1698
1699 pub fn unique(mut self) -> Self {
1701 self.unique = true;
1702 self
1703 }
1704
1705 pub fn using(mut self, method: IndexMethod) -> Self {
1709 self.method = method;
1710 self
1711 }
1712
1713 pub fn partial(mut self, expr: CheckExpr) -> Self {
1715 self.where_clause = Some(expr);
1716 self
1717 }
1718
1719 pub fn include(mut self, cols: Vec<String>) -> Self {
1721 self.include = cols;
1722 self
1723 }
1724
1725 pub fn concurrently(mut self) -> Self {
1727 self.concurrently = true;
1728 self
1729 }
1730}
1731
1732fn fk_action_str(action: &FkAction) -> &'static str {
1735 match action {
1736 FkAction::NoAction => "no_action",
1737 FkAction::Cascade => "cascade",
1738 FkAction::SetNull => "set_null",
1739 FkAction::SetDefault => "set_default",
1740 FkAction::Restrict => "restrict",
1741 }
1742}
1743
1744fn format_qail_value_token(value: &str, extra_special: &[char]) -> String {
1745 let needs_quotes = value.is_empty()
1746 || value.chars().any(|ch| {
1747 ch.is_whitespace() || matches!(ch, ',' | '\'' | '"') || extra_special.contains(&ch)
1748 });
1749
1750 if needs_quotes {
1751 format!("\"{}\"", value.replace('"', "\"\""))
1752 } else {
1753 value.to_string()
1754 }
1755}
1756
1757fn format_check_in_value(value: &str) -> String {
1758 format_qail_value_token(value, &['[', ']'])
1759}
1760
1761fn format_sql_text_literal(value: &str) -> String {
1762 format!("'{}'", value.replace('\'', "''"))
1763}
1764
1765fn format_sql_text_literal_with_cast(value: &str, cast: &Option<String>) -> String {
1766 let literal = format_sql_text_literal(value);
1767 match cast {
1768 Some(cast) => format!("{literal}::{cast}"),
1769 None => literal,
1770 }
1771}
1772
1773fn check_expr_str(expr: &CheckExpr) -> String {
1775 match expr {
1776 CheckExpr::GreaterThan { column, value } => format!("{} > {}", column, value),
1777 CheckExpr::GreaterOrEqual { column, value } => format!("{} >= {}", column, value),
1778 CheckExpr::LessThan { column, value } => format!("{} < {}", column, value),
1779 CheckExpr::LessOrEqual { column, value } => format!("{} <= {}", column, value),
1780 CheckExpr::Between { column, low, high } => format!("{} between {} {}", column, low, high),
1781 CheckExpr::In { column, values } => format!(
1782 "{} in [{}]",
1783 column,
1784 values
1785 .iter()
1786 .map(|value| format_check_in_value(value))
1787 .collect::<Vec<_>>()
1788 .join(", ")
1789 ),
1790 CheckExpr::InIntegers { column, values } => format!(
1791 "{} = ANY (ARRAY[{}])",
1792 column,
1793 values
1794 .iter()
1795 .map(i64::to_string)
1796 .collect::<Vec<_>>()
1797 .join(", ")
1798 ),
1799 CheckExpr::CompareColumns {
1800 left_column,
1801 op,
1802 right_column,
1803 } => format!("{} {} {}", left_column, op.as_sql_str(), right_column),
1804 CheckExpr::TextCompare { column, op, value } => {
1805 format!(
1806 "{} {} {}",
1807 column,
1808 op.as_sql_str(),
1809 format_sql_text_literal(value)
1810 )
1811 }
1812 CheckExpr::CompareColumnToCoalesce {
1813 left_column,
1814 op,
1815 coalesce_column,
1816 fallback,
1817 fallback_cast,
1818 } => format!(
1819 "{} {} COALESCE({}, {})",
1820 left_column,
1821 op.as_sql_str(),
1822 coalesce_column,
1823 format_sql_text_literal_with_cast(fallback, fallback_cast)
1824 ),
1825 CheckExpr::LowerTrimEquals { column } => format!("{column} = lower(btrim({column}))"),
1826 CheckExpr::Regex { column, pattern } => {
1827 format!("{} ~ {}", column, format_sql_text_literal(pattern))
1828 }
1829 CheckExpr::MaxLength { column, max } => format!("length({}) <= {}", column, max),
1830 CheckExpr::MinLength { column, min } => format!("length({}) >= {}", column, min),
1831 CheckExpr::NotNull { column } => format!("{} not_null", column),
1832 CheckExpr::And(l, r) => format!("{} and {}", check_expr_str(l), check_expr_str(r)),
1833 CheckExpr::Or(l, r) => format!("{} or {}", check_expr_str(l), check_expr_str(r)),
1834 CheckExpr::Not(e) => format!("not {}", check_expr_str(e)),
1835 CheckExpr::Sql(sql) => sql.clone(),
1836 }
1837}
1838
1839fn format_enum_value(value: &str) -> String {
1840 format_qail_value_token(value, &['{', '}'])
1841}
1842
1843fn dollar_quote_qail_body(body: &str) -> String {
1844 let delimiter = if !body.contains("$$") {
1845 "$$".to_string()
1846 } else {
1847 let mut idx = 0usize;
1848 loop {
1849 let candidate = if idx == 0 {
1850 "$qail$".to_string()
1851 } else {
1852 format!("$qail{idx}$")
1853 };
1854 if !body.contains(&candidate) {
1855 break candidate;
1856 }
1857 idx = idx.saturating_add(1);
1858 }
1859 };
1860
1861 format!("{delimiter}\n{body}\n{delimiter}")
1862}
1863
1864pub fn to_qail_string(schema: &Schema) -> String {
1866 let mut output = String::new();
1867 output.push_str("# QAIL Schema\n\n");
1868
1869 for ext in &schema.extensions {
1871 let mut line = format!("extension {}", quote_qail_string(&ext.name));
1872 if let Some(ref s) = ext.schema {
1873 line.push_str(&format!(" schema {}", quote_qail_string(s)));
1874 }
1875 if let Some(ref v) = ext.version {
1876 line.push_str(&format!(" version {}", quote_qail_string(v)));
1877 }
1878 output.push_str(&line);
1879 output.push('\n');
1880 }
1881 if !schema.extensions.is_empty() {
1882 output.push('\n');
1883 }
1884
1885 for enum_type in &schema.enums {
1887 let values = enum_type
1888 .values
1889 .iter()
1890 .map(|v| format_enum_value(v))
1891 .collect::<Vec<_>>()
1892 .join(", ");
1893 output.push_str(&format!("enum {} {{ {} }}\n", enum_type.name, values));
1894 }
1895 if !schema.enums.is_empty() {
1896 output.push('\n');
1897 }
1898
1899 for seq in &schema.sequences {
1901 if seq.start.is_some()
1902 || seq.increment.is_some()
1903 || seq.min_value.is_some()
1904 || seq.max_value.is_some()
1905 || seq.cache.is_some()
1906 || seq.cycle
1907 || seq.owned_by.is_some()
1908 {
1909 let mut opts = Vec::new();
1910 if let Some(v) = seq.start {
1911 opts.push(format!("start {}", v));
1912 }
1913 if let Some(v) = seq.increment {
1914 opts.push(format!("increment {}", v));
1915 }
1916 if let Some(v) = seq.min_value {
1917 opts.push(format!("minvalue {}", v));
1918 }
1919 if let Some(v) = seq.max_value {
1920 opts.push(format!("maxvalue {}", v));
1921 }
1922 if let Some(v) = seq.cache {
1923 opts.push(format!("cache {}", v));
1924 }
1925 if seq.cycle {
1926 opts.push("cycle".to_string());
1927 }
1928 if let Some(ref o) = seq.owned_by {
1929 opts.push(format!("owned_by {}", o));
1930 }
1931 output.push_str(&format!("sequence {} {{ {} }}\n", seq.name, opts.join(" ")));
1932 } else {
1933 output.push_str(&format!("sequence {}\n", seq.name));
1934 }
1935 }
1936 if !schema.sequences.is_empty() {
1937 output.push('\n');
1938 }
1939
1940 let mut table_names: Vec<&String> = schema.tables.keys().collect();
1941 table_names.sort();
1942 for table_name in table_names {
1943 let table = &schema.tables[table_name];
1944 output.push_str(&format!("table {} {{\n", table.name));
1945 for col in &table.columns {
1946 let mut constraints: Vec<String> = Vec::new();
1947 if col.primary_key {
1948 constraints.push("primary_key".to_string());
1949 }
1950 if !col.nullable && !col.primary_key {
1951 constraints.push("not_null".to_string());
1952 }
1953 if col.unique {
1954 constraints.push("unique".to_string());
1955 }
1956 if let Some(def) = &col.default {
1957 constraints.push(format!("default {}", def));
1958 }
1959 if let Some(generated) = &col.generated {
1960 match generated {
1961 Generated::AlwaysStored(expr) => {
1962 constraints.push(format!("generated_stored({})", expr));
1963 }
1964 Generated::AlwaysIdentity => {
1965 constraints.push("generated_identity".to_string());
1966 }
1967 Generated::ByDefaultIdentity => {
1968 constraints.push("generated_by_default_identity".to_string());
1969 }
1970 }
1971 }
1972 if let Some(ref fk) = col.foreign_key {
1973 let mut fk_str = format!("references {}({})", fk.table, fk.column);
1974 if fk.on_delete != FkAction::NoAction {
1975 fk_str.push_str(&format!(" on_delete {}", fk_action_str(&fk.on_delete)));
1976 }
1977 if fk.on_update != FkAction::NoAction {
1978 fk_str.push_str(&format!(" on_update {}", fk_action_str(&fk.on_update)));
1979 }
1980 match &fk.deferrable {
1981 Deferrable::Deferrable => fk_str.push_str(" deferrable"),
1982 Deferrable::InitiallyDeferred => fk_str.push_str(" initially_deferred"),
1983 Deferrable::InitiallyImmediate => fk_str.push_str(" initially_immediate"),
1984 Deferrable::NotDeferrable => {} }
1986 constraints.push(fk_str);
1987 }
1988 if let Some(ref check) = col.check {
1989 constraints.push(format!("check({})", check_expr_str(&check.expr)));
1990 if let Some(name) = &check.name {
1991 constraints.push(format!("check_name {}", name));
1992 }
1993 }
1994
1995 let constraint_str = if constraints.is_empty() {
1996 String::new()
1997 } else {
1998 format!(" {}", constraints.join(" "))
1999 };
2000
2001 output.push_str(&format!(
2002 " {} {}{}\n",
2003 col.name,
2004 col.data_type.to_pg_type(),
2005 constraint_str
2006 ));
2007 }
2008 for fk in &table.multi_column_fks {
2010 let mut fk_line = format!(
2011 " foreign_key ({}) references {}({})\n",
2012 fk.columns.join(", "),
2013 fk.ref_table,
2014 fk.ref_columns.join(", ")
2015 );
2016 if fk.name.is_some()
2017 || fk.on_delete != FkAction::NoAction
2018 || fk.on_update != FkAction::NoAction
2019 || fk.deferrable != Deferrable::NotDeferrable
2020 {
2021 fk_line.pop();
2022 if let Some(name) = &fk.name {
2023 fk_line.push_str(&format!(" constraint {}", name));
2024 }
2025 if fk.on_delete != FkAction::NoAction {
2026 fk_line.push_str(&format!(" on_delete {}", fk_action_str(&fk.on_delete)));
2027 }
2028 if fk.on_update != FkAction::NoAction {
2029 fk_line.push_str(&format!(" on_update {}", fk_action_str(&fk.on_update)));
2030 }
2031 match &fk.deferrable {
2032 Deferrable::Deferrable => fk_line.push_str(" deferrable"),
2033 Deferrable::InitiallyDeferred => fk_line.push_str(" initially_deferred"),
2034 Deferrable::InitiallyImmediate => fk_line.push_str(" initially_immediate"),
2035 Deferrable::NotDeferrable => {}
2036 }
2037 fk_line.push('\n');
2038 }
2039 output.push_str(&fk_line);
2040 }
2041 if table.enable_rls {
2043 output.push_str(" enable_rls\n");
2044 }
2045 if table.force_rls {
2046 output.push_str(" force_rls\n");
2047 }
2048 output.push_str("}\n\n");
2049 }
2050
2051 for idx in &schema.indexes {
2052 let unique = if idx.unique { "unique " } else { "" };
2053 let concurrently = if idx.concurrently {
2054 "concurrently "
2055 } else {
2056 ""
2057 };
2058 let cols = if !idx.expressions.is_empty() {
2059 idx.expressions.join(", ")
2060 } else {
2061 idx.columns.join(", ")
2062 };
2063 let mut line = format!(
2064 "{}index {}{} on {}",
2065 unique, concurrently, idx.name, idx.table
2066 );
2067 if idx.method != IndexMethod::BTree {
2068 line.push_str(" using ");
2069 line.push_str(index_method_str(&idx.method));
2070 }
2071 line.push_str(" (");
2072 line.push_str(&cols);
2073 line.push(')');
2074 if !idx.include.is_empty() {
2075 line.push_str(" include (");
2076 line.push_str(&idx.include.join(", "));
2077 line.push(')');
2078 }
2079 if let Some(where_clause) = &idx.where_clause {
2080 line.push_str(" where ");
2081 line.push_str(&check_expr_str(where_clause));
2082 }
2083 output.push_str(&line);
2084 output.push('\n');
2085 }
2086
2087 for hint in &schema.migrations {
2088 match hint {
2089 MigrationHint::Rename { from, to } => {
2090 output.push_str(&format!("rename {} -> {}\n", from, to));
2091 }
2092 MigrationHint::Transform { expression, target } => {
2093 output.push_str(&format!("transform {} -> {}\n", expression, target));
2094 }
2095 MigrationHint::Drop { target, confirmed } => {
2096 let confirm = if *confirmed { " confirm" } else { "" };
2097 output.push_str(&format!("drop {}{}\n", target, confirm));
2098 }
2099 }
2100 }
2101
2102 for view in &schema.views {
2104 let prefix = if view.materialized {
2105 "materialized view"
2106 } else {
2107 "view"
2108 };
2109 let body = dollar_quote_qail_body(&view.query);
2110 output.push_str(&format!("{} {} {}\n\n", prefix, view.name, body));
2111 }
2112
2113 for func in &schema.functions {
2115 let args = func.args.join(", ");
2116 let volatility = func
2117 .volatility
2118 .as_deref()
2119 .filter(|v| !v.trim().is_empty())
2120 .map(|v| format!(" {}", v))
2121 .unwrap_or_default();
2122 let body = dollar_quote_qail_body(&func.body);
2123 output.push_str(&format!(
2124 "function {}({}) returns {} language {}{} {}\n\n",
2125 func.name, args, func.returns, func.language, volatility, body
2126 ));
2127 }
2128
2129 for trigger in &schema.triggers {
2131 let mut events = Vec::new();
2132 for evt in &trigger.events {
2133 if evt.eq_ignore_ascii_case("UPDATE") && !trigger.update_columns.is_empty() {
2134 events.push(format!("UPDATE OF {}", trigger.update_columns.join(", ")));
2135 } else {
2136 events.push(evt.clone());
2137 }
2138 }
2139 output.push_str(&format!(
2140 "trigger {} on {} {} {} execute {}\n",
2141 trigger.name,
2142 trigger.table,
2143 trigger.timing.to_lowercase(),
2144 events.join(" or ").to_lowercase(),
2145 trigger.execute_function
2146 ));
2147 }
2148 if !schema.triggers.is_empty() {
2149 output.push('\n');
2150 }
2151
2152 for policy in &schema.policies {
2154 let cmd = match policy.target {
2155 PolicyTarget::All => "all",
2156 PolicyTarget::Select => "select",
2157 PolicyTarget::Insert => "insert",
2158 PolicyTarget::Update => "update",
2159 PolicyTarget::Delete => "delete",
2160 };
2161 let perm = match policy.permissiveness {
2162 PolicyPermissiveness::Permissive => "",
2163 PolicyPermissiveness::Restrictive => " restrictive",
2164 };
2165 let role_str = match &policy.role {
2166 Some(r) => format!(" to {}", r),
2167 None => String::new(),
2168 };
2169 output.push_str(&format!(
2170 "policy {} on {} for {}{}{}",
2171 policy.name, policy.table, cmd, role_str, perm
2172 ));
2173 if let Some(ref using) = policy.using {
2174 output.push_str(&format!("\n using $$ {} $$", using));
2175 }
2176 if let Some(ref wc) = policy.with_check {
2177 output.push_str(&format!("\n with_check $$ {} $$", wc));
2178 }
2179 output.push_str("\n\n");
2180 }
2181
2182 for grant in &schema.grants {
2184 let privs: Vec<String> = grant
2185 .privileges
2186 .iter()
2187 .map(|p| p.to_string().to_lowercase())
2188 .collect();
2189 match grant.action {
2190 GrantAction::Grant => {
2191 output.push_str(&format!(
2192 "grant {} on {} to {}\n",
2193 privs.join(", "),
2194 grant.on_object,
2195 grant.to_role
2196 ));
2197 }
2198 GrantAction::Revoke => {
2199 output.push_str(&format!(
2200 "revoke {} on {} from {}\n",
2201 privs.join(", "),
2202 grant.on_object,
2203 grant.to_role
2204 ));
2205 }
2206 }
2207 }
2208 if !schema.grants.is_empty() {
2209 output.push('\n');
2210 }
2211
2212 for comment in &schema.comments {
2214 let text = quote_qail_string(&comment.text);
2215 match &comment.target {
2216 CommentTarget::Table(t) => {
2217 output.push_str(&format!("comment on {} {}\n", t, text));
2218 }
2219 CommentTarget::Column { table, column } => {
2220 output.push_str(&format!("comment on {}.{} {}\n", table, column, text));
2221 }
2222 CommentTarget::Raw(target) => {
2223 output.push_str(&format!("comment on {} {}\n", target, text));
2224 }
2225 }
2226 }
2227
2228 output
2229}
2230
2231fn quote_qail_string(value: &str) -> String {
2232 format!("\"{}\"", value.replace('"', "\"\""))
2233}
2234
2235pub fn schema_to_commands(schema: &Schema) -> Vec<crate::ast::Qail> {
2238 use crate::ast::{Action, ColumnGeneration, Constraint, Expr, IndexDef, Qail};
2239
2240 let mut cmds = Vec::new();
2241
2242 let mut indegree: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
2245 let mut reverse_adj: std::collections::HashMap<String, Vec<String>> =
2246 std::collections::HashMap::new();
2247
2248 for name in schema.tables.keys() {
2249 indegree.insert(name.clone(), 0);
2250 }
2251
2252 for table in schema.tables.values() {
2253 let mut deps = std::collections::HashSet::new();
2254 for col in &table.columns {
2255 if let Some(fk) = &col.foreign_key
2256 && fk.table != table.name
2257 && schema.tables.contains_key(&fk.table)
2258 {
2259 deps.insert(fk.table.clone());
2260 }
2261 }
2262 for fk in &table.multi_column_fks {
2263 if fk.ref_table != table.name && schema.tables.contains_key(&fk.ref_table) {
2264 deps.insert(fk.ref_table.clone());
2265 }
2266 }
2267
2268 indegree.insert(table.name.clone(), deps.len());
2269 for dep in deps {
2270 reverse_adj.entry(dep).or_default().push(table.name.clone());
2271 }
2272 }
2273
2274 let mut ready = std::collections::BTreeSet::new();
2275 for (name, deg) in &indegree {
2276 if *deg == 0 {
2277 ready.insert(name.clone());
2278 }
2279 }
2280
2281 let mut ordered_names: Vec<String> = Vec::with_capacity(schema.tables.len());
2282 while let Some(next) = ready.pop_first() {
2283 ordered_names.push(next.clone());
2284 if let Some(dependents) = reverse_adj.get(&next) {
2285 for dep_name in dependents {
2286 if let Some(d) = indegree.get_mut(dep_name)
2287 && *d > 0
2288 {
2289 *d -= 1;
2290 if *d == 0 {
2291 ready.insert(dep_name.clone());
2292 }
2293 }
2294 }
2295 }
2296 }
2297
2298 if ordered_names.len() < schema.tables.len() {
2301 let mut leftovers: Vec<String> = schema
2302 .tables
2303 .keys()
2304 .filter(|name| !ordered_names.contains(*name))
2305 .cloned()
2306 .collect();
2307 leftovers.sort();
2308 ordered_names.extend(leftovers);
2309 }
2310
2311 for table_name in ordered_names {
2312 let table = &schema.tables[&table_name];
2313 let columns: Vec<Expr> = table
2315 .columns
2316 .iter()
2317 .map(|col| {
2318 let mut constraints = Vec::new();
2319
2320 if col.primary_key {
2321 constraints.push(Constraint::PrimaryKey);
2322 }
2323 if col.nullable {
2324 constraints.push(Constraint::Nullable);
2325 }
2326 if col.unique {
2327 constraints.push(Constraint::Unique);
2328 }
2329 if let Some(def) = &col.default {
2330 constraints.push(Constraint::Default(def.clone()));
2331 }
2332 if let Some(ref fk) = col.foreign_key {
2333 constraints.push(Constraint::References(foreign_key_to_sql(fk)));
2334 }
2335 if let Some(check) = &col.check {
2336 let check_sql = check_expr_to_sql(&check.expr);
2337 if let Some(name) = &check.name {
2338 constraints.push(Constraint::Check(vec![format!(
2339 "CONSTRAINT {} CHECK ({})",
2340 name, check_sql
2341 )]));
2342 } else {
2343 constraints.push(Constraint::Check(vec![check_sql]));
2344 }
2345 }
2346 if let Some(generated) = &col.generated {
2347 let gen_constraint = match generated {
2348 Generated::AlwaysStored(expr) => {
2349 Constraint::Generated(ColumnGeneration::Stored(expr.clone()))
2350 }
2351 Generated::AlwaysIdentity => {
2352 Constraint::Generated(ColumnGeneration::Stored("identity".to_string()))
2353 }
2354 Generated::ByDefaultIdentity => Constraint::Generated(
2355 ColumnGeneration::Stored("identity_by_default".to_string()),
2356 ),
2357 };
2358 constraints.push(gen_constraint);
2359 }
2360
2361 Expr::Def {
2362 name: col.name.clone(),
2363 data_type: col.data_type.to_pg_type(),
2364 constraints,
2365 }
2366 })
2367 .collect();
2368
2369 cmds.push(Qail {
2370 action: Action::Make,
2371 table: table.name.clone(),
2372 columns,
2373 ..Default::default()
2374 });
2375
2376 if table.enable_rls {
2377 cmds.push(Qail {
2378 action: Action::AlterEnableRls,
2379 table: table.name.clone(),
2380 ..Default::default()
2381 });
2382 }
2383 if table.force_rls {
2384 cmds.push(Qail {
2385 action: Action::AlterForceRls,
2386 table: table.name.clone(),
2387 ..Default::default()
2388 });
2389 }
2390 }
2391
2392 for idx in &schema.indexes {
2394 cmds.push(Qail {
2395 action: Action::Index,
2396 table: String::new(),
2397 index_def: Some(IndexDef {
2398 name: idx.name.clone(),
2399 table: idx.table.clone(),
2400 columns: if !idx.expressions.is_empty() {
2401 idx.expressions.clone()
2402 } else {
2403 idx.columns.clone()
2404 },
2405 unique: idx.unique,
2406 index_type: Some(index_method_str(&idx.method).to_string()),
2407 include: idx.include.clone(),
2408 concurrently: idx.concurrently,
2409 where_clause: idx.where_clause.as_ref().map(check_expr_to_sql),
2410 }),
2411 ..Default::default()
2412 });
2413 }
2414
2415 let mut fk_table_names: Vec<&String> = schema
2416 .tables
2417 .iter()
2418 .filter(|(_, table)| !table.multi_column_fks.is_empty())
2419 .map(|(name, _)| name)
2420 .collect();
2421 fk_table_names.sort();
2422 for table_name in fk_table_names {
2423 let table = &schema.tables[table_name];
2424 for fk in &table.multi_column_fks {
2425 cmds.push(multi_column_fk_to_alter_command(&table.name, fk));
2426 }
2427 }
2428
2429 cmds
2430}
2431
2432pub(super) fn multi_column_fk_to_table_constraint(
2433 fk: &MultiColumnForeignKey,
2434) -> crate::ast::TableConstraint {
2435 crate::ast::TableConstraint::ForeignKey {
2436 name: fk.name.clone(),
2437 columns: fk.columns.clone(),
2438 ref_table: fk.ref_table.clone(),
2439 ref_columns: fk.ref_columns.clone(),
2440 on_delete: (fk.on_delete != FkAction::NoAction)
2441 .then(|| fk_action_to_sql(&fk.on_delete).to_string()),
2442 on_update: (fk.on_update != FkAction::NoAction)
2443 .then(|| fk_action_to_sql(&fk.on_update).to_string()),
2444 deferrable: deferrable_to_sql(&fk.deferrable).map(str::to_string),
2445 }
2446}
2447
2448pub(super) fn multi_column_fk_to_alter_command(
2449 table_name: &str,
2450 fk: &MultiColumnForeignKey,
2451) -> crate::ast::Qail {
2452 crate::ast::Qail {
2453 action: crate::ast::Action::Alter,
2454 table: table_name.to_string(),
2455 table_constraints: vec![multi_column_fk_to_table_constraint(fk)],
2456 ..Default::default()
2457 }
2458}
2459
2460fn fk_action_to_sql(action: &FkAction) -> &'static str {
2461 match action {
2462 FkAction::NoAction => "NO ACTION",
2463 FkAction::Cascade => "CASCADE",
2464 FkAction::SetNull => "SET NULL",
2465 FkAction::SetDefault => "SET DEFAULT",
2466 FkAction::Restrict => "RESTRICT",
2467 }
2468}
2469
2470fn deferrable_to_sql(deferrable: &Deferrable) -> Option<&'static str> {
2471 match deferrable {
2472 Deferrable::NotDeferrable => None,
2473 Deferrable::Deferrable => Some("DEFERRABLE"),
2474 Deferrable::InitiallyDeferred => Some("DEFERRABLE INITIALLY DEFERRED"),
2475 Deferrable::InitiallyImmediate => Some("DEFERRABLE INITIALLY IMMEDIATE"),
2476 }
2477}
2478
2479pub(crate) fn foreign_key_to_sql(fk: &ForeignKey) -> String {
2480 let mut target = format!("{}({})", fk.table, fk.column);
2481 if fk.on_delete != FkAction::NoAction {
2482 target.push_str(" ON DELETE ");
2483 target.push_str(fk_action_to_sql(&fk.on_delete));
2484 }
2485 if fk.on_update != FkAction::NoAction {
2486 target.push_str(" ON UPDATE ");
2487 target.push_str(fk_action_to_sql(&fk.on_update));
2488 }
2489 if let Some(def) = deferrable_to_sql(&fk.deferrable) {
2490 target.push(' ');
2491 target.push_str(def);
2492 }
2493 target
2494}
2495
2496pub(crate) fn check_expr_to_sql(expr: &CheckExpr) -> String {
2497 match expr {
2498 CheckExpr::GreaterThan { column, value } => format!("{column} > {value}"),
2499 CheckExpr::GreaterOrEqual { column, value } => format!("{column} >= {value}"),
2500 CheckExpr::LessThan { column, value } => format!("{column} < {value}"),
2501 CheckExpr::LessOrEqual { column, value } => format!("{column} <= {value}"),
2502 CheckExpr::Between { column, low, high } => format!("{column} BETWEEN {low} AND {high}"),
2503 CheckExpr::In { column, values } => {
2504 if values.len() == 1 && looks_like_raw_check_expr(&values[0]) {
2505 return values[0].clone();
2506 }
2507 let quoted = values
2508 .iter()
2509 .map(|v| format!("'{}'", v.replace('\'', "''")))
2510 .collect::<Vec<_>>()
2511 .join(", ");
2512 format!("{column} IN ({quoted})")
2513 }
2514 CheckExpr::InIntegers { column, values } => format!(
2515 "{column} IN ({})",
2516 values
2517 .iter()
2518 .map(i64::to_string)
2519 .collect::<Vec<_>>()
2520 .join(", ")
2521 ),
2522 CheckExpr::CompareColumns {
2523 left_column,
2524 op,
2525 right_column,
2526 } => format!("{left_column} {} {right_column}", op.as_sql_str()),
2527 CheckExpr::TextCompare { column, op, value } => {
2528 format!(
2529 "{column} {} {}",
2530 op.as_sql_str(),
2531 format_sql_text_literal(value)
2532 )
2533 }
2534 CheckExpr::CompareColumnToCoalesce {
2535 left_column,
2536 op,
2537 coalesce_column,
2538 fallback,
2539 fallback_cast,
2540 } => format!(
2541 "{left_column} {} COALESCE({coalesce_column}, {})",
2542 op.as_sql_str(),
2543 format_sql_text_literal_with_cast(fallback, fallback_cast)
2544 ),
2545 CheckExpr::LowerTrimEquals { column } => format!("{column} = lower(btrim({column}))"),
2546 CheckExpr::Regex { column, pattern } => {
2547 format!("{column} ~ {}", format_sql_text_literal(pattern))
2548 }
2549 CheckExpr::MaxLength { column, max } => format!("char_length({column}) <= {max}"),
2550 CheckExpr::MinLength { column, min } => format!("char_length({column}) >= {min}"),
2551 CheckExpr::NotNull { column } => format!("{column} IS NOT NULL"),
2552 CheckExpr::And(left, right) => {
2553 format!(
2554 "({}) AND ({})",
2555 check_expr_to_sql(left),
2556 check_expr_to_sql(right)
2557 )
2558 }
2559 CheckExpr::Or(left, right) => {
2560 format!(
2561 "({}) OR ({})",
2562 check_expr_to_sql(left),
2563 check_expr_to_sql(right)
2564 )
2565 }
2566 CheckExpr::Not(inner) => format!("NOT ({})", check_expr_to_sql(inner)),
2567 CheckExpr::Sql(sql) => sql.clone(),
2568 }
2569}
2570
2571fn looks_like_raw_check_expr(s: &str) -> bool {
2572 s.chars()
2573 .any(|c| c.is_whitespace() || matches!(c, '<' | '>' | '=' | '!' | '(' | ')' | ':'))
2574}
2575
2576#[cfg(test)]
2577mod tests {
2578 use super::*;
2579
2580 #[test]
2581 fn test_schema_builder() {
2582 let mut schema = Schema::new();
2583
2584 let users = Table::new("users")
2585 .column(Column::new("id", ColumnType::Serial).primary_key())
2586 .column(Column::new("name", ColumnType::Text).not_null())
2587 .column(Column::new("email", ColumnType::Text).unique());
2588
2589 schema.add_table(users);
2590 schema.add_index(Index::new("idx_users_email", "users", vec!["email".into()]).unique());
2591
2592 let output = to_qail_string(&schema);
2593 assert!(output.contains("table users"));
2594 assert!(output.contains("id SERIAL primary_key"));
2595 assert!(output.contains("unique index idx_users_email"));
2596 }
2597
2598 #[test]
2599 fn test_to_qail_string_preserves_vector_index_methods() {
2600 let mut schema = Schema::new();
2601 schema.add_index(
2602 Index::new(
2603 "idx_docs_embedding_hnsw",
2604 "documents",
2605 vec!["embedding vector_l2_ops".into()],
2606 )
2607 .using(IndexMethod::Hnsw),
2608 );
2609 schema.add_index(
2610 Index::new(
2611 "idx_docs_embedding_ivfflat",
2612 "documents",
2613 vec!["embedding vector_cosine_ops".into()],
2614 )
2615 .using(IndexMethod::IvfFlat),
2616 );
2617
2618 let output = to_qail_string(&schema);
2619
2620 assert!(output.contains(
2621 "index idx_docs_embedding_hnsw on documents using hnsw (embedding vector_l2_ops)"
2622 ));
2623 assert!(output.contains(
2624 "index idx_docs_embedding_ivfflat on documents using ivfflat (embedding vector_cosine_ops)"
2625 ));
2626 }
2627
2628 #[test]
2629 fn test_to_qail_string_preserves_covering_concurrent_index_options() {
2630 let mut schema = Schema::new();
2631 schema.add_index(
2632 Index::new("idx_users_email_cover", "users", vec!["email".into()])
2633 .unique()
2634 .include(vec!["name".into(), "created_at".into()])
2635 .concurrently()
2636 .partial(CheckExpr::Sql("deleted_at IS NULL".to_string())),
2637 );
2638
2639 let output = to_qail_string(&schema);
2640
2641 assert!(output.contains(
2642 "unique index concurrently idx_users_email_cover on users (email) include (name, created_at) where deleted_at IS NULL"
2643 ));
2644 }
2645
2646 #[test]
2647 fn test_migration_hints() {
2648 let mut schema = Schema::new();
2649 schema.add_hint(MigrationHint::Rename {
2650 from: "users.username".into(),
2651 to: "users.name".into(),
2652 });
2653
2654 let output = to_qail_string(&schema);
2655 assert!(output.contains("rename users.username -> users.name"));
2656 }
2657
2658 #[test]
2659 fn test_to_qail_string_includes_function_volatility() {
2660 let mut schema = Schema::new();
2661 let func = SchemaFunctionDef::new(
2662 "is_super_admin",
2663 "boolean",
2664 "BEGIN RETURN true; END;".to_string(),
2665 )
2666 .language("plpgsql")
2667 .volatility("stable");
2668 schema.add_function(func);
2669
2670 let output = to_qail_string(&schema);
2671 assert!(
2672 output.contains("function is_super_admin() returns boolean language plpgsql stable $$")
2673 );
2674 }
2675
2676 #[test]
2677 fn test_invalid_primary_key_type_strict() {
2678 let err = Column::new("data", ColumnType::Jsonb)
2679 .try_primary_key()
2680 .expect_err("JSONB should be rejected by strict PK policy");
2681 assert!(err.contains("cannot be a primary key"));
2682 }
2683
2684 #[test]
2685 fn test_invalid_primary_key_type_fail_soft() {
2686 let col = Column::new("data", ColumnType::Jsonb).primary_key();
2687 assert!(col.primary_key);
2688 assert!(!col.nullable);
2689 }
2690
2691 #[test]
2692 fn test_invalid_unique_type_strict() {
2693 let err = Column::new("data", ColumnType::Jsonb)
2694 .try_unique()
2695 .expect_err("JSONB should be rejected by strict UNIQUE policy");
2696 assert!(err.contains("cannot have UNIQUE"));
2697 }
2698
2699 #[test]
2700 fn test_invalid_unique_type_fail_soft() {
2701 let col = Column::new("data", ColumnType::Jsonb).unique();
2702 assert!(col.unique);
2703 }
2704
2705 #[test]
2706 fn test_validate_rejects_invalid_primary_key_type() {
2707 let mut schema = Schema::new();
2708 schema.add_table(
2709 Table::new("events").column(Column::new("data", ColumnType::Jsonb).primary_key()),
2710 );
2711
2712 let errors = schema
2713 .validate()
2714 .expect_err("invalid primary-key type should fail validation");
2715 assert!(
2716 errors.iter().any(|err| {
2717 err.contains("events.data")
2718 && err.contains("JSONB")
2719 && err.contains("cannot be a primary key")
2720 }),
2721 "{errors:?}"
2722 );
2723 }
2724
2725 #[test]
2726 fn test_validate_rejects_invalid_unique_type() {
2727 let mut schema = Schema::new();
2728 schema.add_table(
2729 Table::new("events").column(Column::new("data", ColumnType::Jsonb).unique()),
2730 );
2731
2732 let errors = schema
2733 .validate()
2734 .expect_err("invalid unique type should fail validation");
2735 assert!(
2736 errors.iter().any(|err| {
2737 err.contains("events.data")
2738 && err.contains("JSONB")
2739 && err.contains("cannot have UNIQUE")
2740 }),
2741 "{errors:?}"
2742 );
2743 }
2744
2745 #[test]
2746 fn test_foreign_key_valid() {
2747 let mut schema = Schema::new();
2748
2749 schema.add_table(
2750 Table::new("users").column(Column::new("id", ColumnType::Uuid).primary_key()),
2751 );
2752
2753 schema.add_table(
2754 Table::new("posts")
2755 .column(Column::new("id", ColumnType::Uuid).primary_key())
2756 .column(
2757 Column::new("user_id", ColumnType::Uuid)
2758 .references("users", "id")
2759 .on_delete(FkAction::Cascade),
2760 ),
2761 );
2762
2763 assert!(schema.validate().is_ok());
2765 }
2766
2767 #[test]
2768 fn test_foreign_key_invalid_table() {
2769 let mut schema = Schema::new();
2770
2771 schema.add_table(
2772 Table::new("posts")
2773 .column(Column::new("id", ColumnType::Uuid).primary_key())
2774 .column(Column::new("user_id", ColumnType::Uuid).references("nonexistent", "id")),
2775 );
2776
2777 let result = schema.validate();
2779 assert!(result.is_err());
2780 assert!(result.unwrap_err()[0].contains("non-existent table"));
2781 }
2782
2783 #[test]
2784 fn test_foreign_key_invalid_column() {
2785 let mut schema = Schema::new();
2786
2787 schema.add_table(
2788 Table::new("users").column(Column::new("id", ColumnType::Uuid).primary_key()),
2789 );
2790
2791 schema.add_table(
2792 Table::new("posts")
2793 .column(Column::new("id", ColumnType::Uuid).primary_key())
2794 .column(
2795 Column::new("user_id", ColumnType::Uuid).references("users", "wrong_column"),
2796 ),
2797 );
2798
2799 let result = schema.validate();
2801 assert!(result.is_err());
2802 assert!(result.unwrap_err()[0].contains("non-existent column"));
2803 }
2804
2805 #[test]
2806 fn test_foreign_key_requires_unique_target() {
2807 let mut schema = Schema::new();
2808 schema.add_table(Table::new("users").column(Column::new("email", ColumnType::Text)));
2809 schema.add_table(
2810 Table::new("posts")
2811 .column(Column::new("id", ColumnType::Uuid).primary_key())
2812 .column(Column::new("author_email", ColumnType::Text).references("users", "email")),
2813 );
2814
2815 let errors = schema
2816 .validate()
2817 .expect_err("FK targets must be unique or primary-key backed");
2818 assert!(
2819 errors.iter().any(|err| err.contains("posts.author_email")
2820 && err.contains("without a UNIQUE or PRIMARY KEY constraint")),
2821 "{errors:?}"
2822 );
2823 }
2824
2825 #[test]
2826 fn test_multi_column_foreign_key_invalid_table_and_columns() {
2827 let mut schema = Schema::new();
2828 schema.add_table(
2829 Table::new("trips")
2830 .column(Column::new("route_id", ColumnType::Text))
2831 .foreign_key(MultiColumnForeignKey::new(
2832 vec!["route_id".to_string(), "schedule_id".to_string()],
2833 "schedules",
2834 vec!["route_id".to_string(), "schedule_id".to_string()],
2835 )),
2836 );
2837
2838 let errors = schema
2839 .validate()
2840 .expect_err("invalid composite FK should fail validation");
2841 assert!(
2842 errors
2843 .iter()
2844 .any(|err| err.contains("non-existent source column 'trips.schedule_id'")),
2845 "{errors:?}"
2846 );
2847 assert!(
2848 errors
2849 .iter()
2850 .any(|err| err.contains("non-existent table 'schedules'")),
2851 "{errors:?}"
2852 );
2853 }
2854
2855 #[test]
2856 fn test_multi_column_foreign_key_invalid_target_column_and_arity() {
2857 let mut schema = Schema::new();
2858 schema.add_table(Table::new("schedules").column(Column::new("route_id", ColumnType::Text)));
2859 schema.add_table(
2860 Table::new("trips")
2861 .column(Column::new("route_id", ColumnType::Text))
2862 .foreign_key(MultiColumnForeignKey::new(
2863 vec!["route_id".to_string()],
2864 "schedules",
2865 vec!["route_id".to_string(), "schedule_id".to_string()],
2866 )),
2867 );
2868
2869 let errors = schema
2870 .validate()
2871 .expect_err("invalid composite FK should fail validation");
2872 assert!(
2873 errors.iter().any(|err| err.contains("column count 1")),
2874 "{errors:?}"
2875 );
2876 assert!(
2877 errors
2878 .iter()
2879 .any(|err| err.contains("non-existent column 'schedules.schedule_id'")),
2880 "{errors:?}"
2881 );
2882 }
2883
2884 #[test]
2885 fn test_multi_column_foreign_key_requires_unique_target() {
2886 let mut schema = Schema::new();
2887 schema.add_table(
2888 Table::new("schedules")
2889 .column(Column::new("route_id", ColumnType::Text))
2890 .column(Column::new("schedule_id", ColumnType::Text)),
2891 );
2892 schema.add_table(
2893 Table::new("trips")
2894 .column(Column::new("route_id", ColumnType::Text))
2895 .column(Column::new("schedule_id", ColumnType::Text))
2896 .foreign_key(MultiColumnForeignKey::new(
2897 vec!["route_id".to_string(), "schedule_id".to_string()],
2898 "schedules",
2899 vec!["route_id".to_string(), "schedule_id".to_string()],
2900 )),
2901 );
2902
2903 let errors = schema
2904 .validate()
2905 .expect_err("composite FK targets must have a matching unique key");
2906 assert!(
2907 errors.iter().any(|err| {
2908 err.contains("Multi-column FK error")
2909 && err.contains("schedules(route_id, schedule_id)")
2910 && err.contains("matching UNIQUE or PRIMARY KEY")
2911 }),
2912 "{errors:?}"
2913 );
2914 }
2915
2916 #[test]
2917 fn test_multi_column_foreign_key_valid_with_unique_index() {
2918 let mut schema = Schema::new();
2919 schema.add_table(
2920 Table::new("schedules")
2921 .column(Column::new("route_id", ColumnType::Text))
2922 .column(Column::new("schedule_id", ColumnType::Text)),
2923 );
2924 schema.add_index(
2925 Index::new(
2926 "schedules_route_schedule_key",
2927 "schedules",
2928 vec!["route_id".to_string(), "schedule_id".to_string()],
2929 )
2930 .unique(),
2931 );
2932 schema.add_table(
2933 Table::new("trips")
2934 .column(Column::new("route_id", ColumnType::Text))
2935 .column(Column::new("schedule_id", ColumnType::Text))
2936 .foreign_key(MultiColumnForeignKey::new(
2937 vec!["route_id".to_string(), "schedule_id".to_string()],
2938 "schedules",
2939 vec!["route_id".to_string(), "schedule_id".to_string()],
2940 )),
2941 );
2942
2943 assert!(schema.validate().is_ok());
2944 }
2945
2946 #[test]
2947 fn test_validate_rejects_duplicate_columns() {
2948 let mut schema = Schema::new();
2949 schema.add_table(
2950 Table::new("users")
2951 .column(Column::new("email", ColumnType::Text))
2952 .column(Column::new("email", ColumnType::Text)),
2953 );
2954
2955 let errors = schema
2956 .validate()
2957 .expect_err("duplicate columns should fail validation");
2958 assert!(
2959 errors
2960 .iter()
2961 .any(|err| err.contains("duplicate column 'email'")),
2962 "{errors:?}"
2963 );
2964 }
2965
2966 #[test]
2967 fn test_validate_rejects_duplicate_index_names() {
2968 let mut schema = Schema::new();
2969 schema.add_table(Table::new("users").column(Column::new("email", ColumnType::Text)));
2970 schema.add_index(Index::new(
2971 "idx_users_email",
2972 "users",
2973 vec!["email".to_string()],
2974 ));
2975 schema.add_index(Index::new(
2976 "idx_users_email",
2977 "users",
2978 vec!["email".to_string()],
2979 ));
2980
2981 let errors = schema
2982 .validate()
2983 .expect_err("duplicate indexes should fail validation");
2984 assert!(
2985 errors
2986 .iter()
2987 .any(|err| err.contains("duplicate index name 'idx_users_email'")),
2988 "{errors:?}"
2989 );
2990 }
2991
2992 #[test]
2993 fn test_validate_rejects_check_on_missing_column() {
2994 let mut schema = Schema::new();
2995 schema.add_table(Table::new("orders").column(
2996 Column::new("status", ColumnType::Text).check(CheckExpr::In {
2997 column: "missing_status".to_string(),
2998 values: vec!["paid".to_string(), "pending".to_string()],
2999 }),
3000 ));
3001
3002 let errors = schema
3003 .validate()
3004 .expect_err("CHECK references should fail validation");
3005 assert!(
3006 errors.iter().any(|err| {
3007 err.contains("CHECK error")
3008 && err.contains("orders.status")
3009 && err.contains("orders.missing_status")
3010 }),
3011 "{errors:?}"
3012 );
3013 }
3014
3015 #[test]
3016 fn test_validate_rejects_nested_check_on_missing_column() {
3017 let mut schema = Schema::new();
3018 schema.add_table(
3019 Table::new("pricing_plans")
3020 .column(Column::new("start_date", ColumnType::Date))
3021 .column(
3022 Column::new("end_date", ColumnType::Date).check(CheckExpr::And(
3023 Box::new(CheckExpr::CompareColumns {
3024 left_column: "end_date".to_string(),
3025 op: CheckComparisonOp::GreaterOrEqual,
3026 right_column: "start_date".to_string(),
3027 }),
3028 Box::new(CheckExpr::CompareColumnToCoalesce {
3029 left_column: "end_date".to_string(),
3030 op: CheckComparisonOp::GreaterOrEqual,
3031 coalesce_column: "missing_fallback_date".to_string(),
3032 fallback: "1970-01-01".to_string(),
3033 fallback_cast: Some("date".to_string()),
3034 }),
3035 )),
3036 ),
3037 );
3038
3039 let errors = schema
3040 .validate()
3041 .expect_err("nested CHECK references should fail validation");
3042 assert!(
3043 errors
3044 .iter()
3045 .any(|err| err.contains("pricing_plans.missing_fallback_date")),
3046 "{errors:?}"
3047 );
3048 }
3049
3050 #[test]
3051 fn test_validate_rejects_duplicate_check_constraint_names() {
3052 let mut schema = Schema::new();
3053 schema.add_table(
3054 Table::new("orders")
3055 .column(Column::new("status", ColumnType::Text).check_named(
3056 "orders_status_check",
3057 CheckExpr::In {
3058 column: "status".to_string(),
3059 values: vec!["pending".to_string(), "paid".to_string()],
3060 },
3061 ))
3062 .column(Column::new("payment_status", ColumnType::Text).check_named(
3063 "orders_status_check",
3064 CheckExpr::In {
3065 column: "payment_status".to_string(),
3066 values: vec!["pending".to_string(), "paid".to_string()],
3067 },
3068 )),
3069 );
3070
3071 let errors = schema
3072 .validate()
3073 .expect_err("duplicate constraint names should fail validation");
3074 assert!(
3075 errors
3076 .iter()
3077 .any(|err| { err.contains("duplicate constraint name 'orders_status_check'") }),
3078 "{errors:?}"
3079 );
3080 }
3081
3082 #[test]
3083 fn test_validate_rejects_duplicate_check_and_fk_constraint_names() {
3084 let mut schema = Schema::new();
3085 schema.add_table(
3086 Table::new("schedules")
3087 .column(Column::new("route_id", ColumnType::Text))
3088 .column(Column::new("schedule_id", ColumnType::Text)),
3089 );
3090 schema.add_index(
3091 Index::new(
3092 "schedules_route_schedule_key",
3093 "schedules",
3094 vec!["route_id".to_string(), "schedule_id".to_string()],
3095 )
3096 .unique(),
3097 );
3098 schema.add_table(
3099 Table::new("trips")
3100 .column(Column::new("route_id", ColumnType::Text).check_named(
3101 "trips_schedule_guard",
3102 CheckExpr::NotNull {
3103 column: "route_id".to_string(),
3104 },
3105 ))
3106 .column(Column::new("schedule_id", ColumnType::Text))
3107 .foreign_key(
3108 MultiColumnForeignKey::new(
3109 vec!["route_id".to_string(), "schedule_id".to_string()],
3110 "schedules",
3111 vec!["route_id".to_string(), "schedule_id".to_string()],
3112 )
3113 .named("trips_schedule_guard"),
3114 ),
3115 );
3116
3117 let errors = schema
3118 .validate()
3119 .expect_err("duplicate constraint names across constraint kinds should fail");
3120 assert!(
3121 errors
3122 .iter()
3123 .any(|err| { err.contains("duplicate constraint name 'trips_schedule_guard'") }),
3124 "{errors:?}"
3125 );
3126 }
3127
3128 #[test]
3129 fn test_validate_rejects_empty_constraint_names() {
3130 let mut schema = Schema::new();
3131 schema.add_table(Table::new("orders").column(
3132 Column::new("status", ColumnType::Text).check_named(
3133 " ",
3134 CheckExpr::NotNull {
3135 column: "status".to_string(),
3136 },
3137 ),
3138 ));
3139
3140 let errors = schema
3141 .validate()
3142 .expect_err("empty constraint names should fail validation");
3143 assert!(
3144 errors
3145 .iter()
3146 .any(|err| err.contains("empty CHECK constraint name")),
3147 "{errors:?}"
3148 );
3149 }
3150
3151 #[test]
3152 fn test_validate_rejects_index_on_missing_table_or_column() {
3153 let mut schema = Schema::new();
3154 schema.add_table(Table::new("users").column(Column::new("email", ColumnType::Text)));
3155 schema.add_index(Index::new(
3156 "idx_missing_table",
3157 "profiles",
3158 vec!["email".to_string()],
3159 ));
3160 schema.add_index(Index::new(
3161 "idx_missing_column",
3162 "users",
3163 vec!["username".to_string()],
3164 ));
3165
3166 let errors = schema
3167 .validate()
3168 .expect_err("invalid indexes should fail validation");
3169 assert!(
3170 errors
3171 .iter()
3172 .any(|err| err.contains("idx_missing_table") && err.contains("profiles")),
3173 "{errors:?}"
3174 );
3175 assert!(
3176 errors
3177 .iter()
3178 .any(|err| err.contains("idx_missing_column") && err.contains("users.username")),
3179 "{errors:?}"
3180 );
3181 }
3182
3183 #[test]
3184 fn test_validate_rejects_empty_index_definition() {
3185 let mut schema = Schema::new();
3186 schema.add_table(Table::new("users").column(Column::new("email", ColumnType::Text)));
3187 schema.add_index(Index::new("idx_users_empty", "users", vec![]));
3188
3189 let errors = schema
3190 .validate()
3191 .expect_err("empty index definitions should fail validation");
3192 assert!(
3193 errors.iter().any(|err| {
3194 err.contains("idx_users_empty") && err.contains("at least one column or expression")
3195 }),
3196 "{errors:?}"
3197 );
3198 }
3199
3200 #[test]
3201 fn test_validate_rejects_blank_index_column_fragment() {
3202 let mut schema = Schema::new();
3203 schema.add_table(Table::new("users").column(Column::new("email", ColumnType::Text)));
3204 schema.add_index(Index::new(
3205 "idx_users_blank",
3206 "users",
3207 vec![" ".to_string()],
3208 ));
3209
3210 let errors = schema
3211 .validate()
3212 .expect_err("blank index columns should fail validation");
3213 assert!(
3214 errors
3215 .iter()
3216 .any(|err| err.contains("idx_users_blank") && err.contains("empty column")),
3217 "{errors:?}"
3218 );
3219 }
3220
3221 #[test]
3222 fn test_validate_rejects_mixed_index_columns_and_expressions() {
3223 let mut schema = Schema::new();
3224 schema.add_table(Table::new("users").column(Column::new("email", ColumnType::Text)));
3225 let mut index = Index::expression(
3226 "idx_users_email_lower",
3227 "users",
3228 vec!["lower(email)".to_string()],
3229 );
3230 index.columns.push("email".to_string());
3231 schema.add_index(index);
3232
3233 let errors = schema
3234 .validate()
3235 .expect_err("mixed index keys should fail validation");
3236 assert!(
3237 errors.iter().any(|err| {
3238 err.contains("idx_users_email_lower")
3239 && err.contains("cannot mix columns and expressions")
3240 }),
3241 "{errors:?}"
3242 );
3243 }
3244
3245 #[test]
3246 fn test_validate_rejects_missing_index_include_column() {
3247 let mut schema = Schema::new();
3248 schema.add_table(
3249 Table::new("users")
3250 .column(Column::new("email", ColumnType::Text))
3251 .column(Column::new("created_at", ColumnType::Timestamp)),
3252 );
3253 schema.add_index(
3254 Index::new("idx_users_email_cover", "users", vec!["email".to_string()])
3255 .include(vec!["name".to_string()]),
3256 );
3257
3258 let errors = schema
3259 .validate()
3260 .expect_err("invalid INCLUDE column should fail validation");
3261 assert!(
3262 errors
3263 .iter()
3264 .any(|err| { err.contains("idx_users_email_cover") && err.contains("users.name") }),
3265 "{errors:?}"
3266 );
3267 }
3268
3269 #[test]
3270 fn test_validate_rejects_missing_partial_index_predicate_column() {
3271 let mut schema = Schema::new();
3272 schema.add_table(Table::new("users").column(Column::new("email", ColumnType::Text)));
3273 schema.add_index(
3274 Index::new("idx_users_active_email", "users", vec!["email".to_string()]).partial(
3275 CheckExpr::NotNull {
3276 column: "deleted_at".to_string(),
3277 },
3278 ),
3279 );
3280
3281 let errors = schema
3282 .validate()
3283 .expect_err("invalid partial-index predicates should fail validation");
3284 assert!(
3285 errors.iter().any(|err| {
3286 err.contains("idx_users_active_email") && err.contains("users.deleted_at")
3287 }),
3288 "{errors:?}"
3289 );
3290 }
3291
3292 #[test]
3293 fn test_validate_allows_index_sort_direction_and_opclass_columns() {
3294 let mut schema = Schema::new();
3295 schema.add_table(
3296 Table::new("documents")
3297 .column(Column::new(
3298 "embedding",
3299 ColumnType::Array(Box::new(ColumnType::Float)),
3300 ))
3301 .column(Column::new("created_at", ColumnType::Timestamptz)),
3302 );
3303 schema.add_index(
3304 Index::new(
3305 "idx_docs_embedding_hnsw",
3306 "documents",
3307 vec!["embedding vector_l2_ops".to_string()],
3308 )
3309 .using(IndexMethod::Hnsw),
3310 );
3311 schema.add_index(Index::new(
3312 "idx_docs_created_at",
3313 "documents",
3314 vec!["created_at DESC NULLS LAST".to_string()],
3315 ));
3316
3317 assert!(schema.validate().is_ok());
3318 }
3319
3320 #[test]
3321 fn test_schema_to_commands_preserves_fk_actions_and_checks() {
3322 let mut schema = Schema::new();
3323 schema.add_table(
3324 Table::new("orgs").column(Column::new("id", ColumnType::Uuid).primary_key()),
3325 );
3326 schema.add_table(
3327 Table::new("users")
3328 .column(Column::new("id", ColumnType::Uuid).primary_key())
3329 .column(
3330 Column::new("org_id", ColumnType::Uuid)
3331 .references("orgs", "id")
3332 .on_delete(FkAction::Cascade)
3333 .on_update(FkAction::Restrict),
3334 )
3335 .column(
3336 Column::new("age", ColumnType::Int).check(CheckExpr::GreaterOrEqual {
3337 column: "age".to_string(),
3338 value: 18,
3339 }),
3340 ),
3341 );
3342
3343 let cmds = schema_to_commands(&schema);
3344 let users_cmd = cmds
3345 .iter()
3346 .find(|c| c.action == crate::ast::Action::Make && c.table == "users")
3347 .expect("users create command should exist");
3348 let org_id_constraints = users_cmd
3349 .columns
3350 .iter()
3351 .find_map(|e| match e {
3352 crate::ast::Expr::Def {
3353 name, constraints, ..
3354 } if name == "org_id" => Some(constraints),
3355 _ => None,
3356 })
3357 .expect("org_id should exist");
3358 let age_constraints = users_cmd
3359 .columns
3360 .iter()
3361 .find_map(|e| match e {
3362 crate::ast::Expr::Def {
3363 name, constraints, ..
3364 } if name == "age" => Some(constraints),
3365 _ => None,
3366 })
3367 .expect("age should exist");
3368
3369 assert!(
3370 org_id_constraints.iter().any(|c| matches!(
3371 c,
3372 crate::ast::Constraint::References(target)
3373 if target.contains("orgs(id)")
3374 && target.contains("ON DELETE CASCADE")
3375 && target.contains("ON UPDATE RESTRICT")
3376 )),
3377 "foreign key action clauses should be preserved"
3378 );
3379 assert!(
3380 age_constraints
3381 .iter()
3382 .any(|c| matches!(c, crate::ast::Constraint::Check(vals) if vals.len() == 1)),
3383 "check expressions should be preserved"
3384 );
3385 }
3386
3387 #[test]
3388 fn schema_to_commands_preserves_table_rls_flags() {
3389 let mut docs = Table::new("docs").column(Column::new("id", ColumnType::Uuid).primary_key());
3390 docs.enable_rls = true;
3391 docs.force_rls = true;
3392
3393 let mut schema = Schema::new();
3394 schema.add_table(docs);
3395
3396 let cmds = schema_to_commands(&schema);
3397 let make_idx = cmds
3398 .iter()
3399 .position(|cmd| cmd.action == crate::ast::Action::Make && cmd.table == "docs")
3400 .expect("table create command should exist");
3401 let enable_idx = cmds
3402 .iter()
3403 .position(|cmd| cmd.action == crate::ast::Action::AlterEnableRls && cmd.table == "docs")
3404 .expect("enable RLS command should exist");
3405 let force_idx = cmds
3406 .iter()
3407 .position(|cmd| cmd.action == crate::ast::Action::AlterForceRls && cmd.table == "docs")
3408 .expect("force RLS command should exist");
3409
3410 assert!(make_idx < enable_idx);
3411 assert!(enable_idx < force_idx);
3412 }
3413
3414 #[test]
3415 fn schema_to_commands_preserves_multi_column_foreign_keys() {
3416 use crate::transpiler::ToSql;
3417
3418 let mut schema = Schema::new();
3419 schema.add_table(
3420 Table::new("schedules")
3421 .column(Column::new("route_id", ColumnType::Text))
3422 .column(Column::new("schedule_id", ColumnType::Text)),
3423 );
3424 schema.add_index(
3425 Index::new(
3426 "idx_schedules_route_schedule",
3427 "schedules",
3428 vec!["route_id".to_string(), "schedule_id".to_string()],
3429 )
3430 .unique(),
3431 );
3432 schema.add_table(
3433 Table::new("trips")
3434 .column(Column::new("route_id", ColumnType::Text))
3435 .column(Column::new("schedule_id", ColumnType::Text))
3436 .foreign_key(
3437 MultiColumnForeignKey::new(
3438 vec!["route_id".to_string(), "schedule_id".to_string()],
3439 "schedules",
3440 vec!["route_id".to_string(), "schedule_id".to_string()],
3441 )
3442 .named("fk_trips_schedule")
3443 .on_delete(FkAction::Cascade)
3444 .on_update(FkAction::Restrict)
3445 .initially_deferred(),
3446 ),
3447 );
3448
3449 let cmds = schema_to_commands(&schema);
3450 let schedules_idx = cmds
3451 .iter()
3452 .position(|c| c.action == crate::ast::Action::Make && c.table == "schedules")
3453 .expect("schedules create command should exist");
3454 let trips_idx = cmds
3455 .iter()
3456 .position(|c| c.action == crate::ast::Action::Make && c.table == "trips")
3457 .expect("trips create command should exist");
3458 let unique_idx = cmds
3459 .iter()
3460 .position(|c| {
3461 c.action == crate::ast::Action::Index
3462 && c.index_def
3463 .as_ref()
3464 .is_some_and(|idx| idx.name == "idx_schedules_route_schedule")
3465 })
3466 .expect("unique index command should exist");
3467 let add_fk_idx = cmds
3468 .iter()
3469 .position(|c| c.action == crate::ast::Action::Alter && c.table == "trips")
3470 .expect("trips composite foreign key ALTER command should exist");
3471
3472 assert!(schedules_idx < unique_idx);
3473 assert!(trips_idx < unique_idx);
3474 assert!(unique_idx < add_fk_idx);
3475
3476 let trips_cmd = cmds
3477 .iter()
3478 .find(|c| c.action == crate::ast::Action::Make && c.table == "trips")
3479 .expect("trips create command should exist");
3480 assert!(
3481 trips_cmd.table_constraints.is_empty(),
3482 "composite foreign keys should not be emitted inline on CREATE TABLE"
3483 );
3484
3485 let add_fk_cmd = &cmds[add_fk_idx];
3486 assert!(
3487 add_fk_cmd
3488 .table_constraints
3489 .iter()
3490 .any(|constraint| matches!(
3491 constraint,
3492 crate::ast::TableConstraint::ForeignKey {
3493 name,
3494 columns,
3495 ref_table,
3496 ref_columns,
3497 on_delete,
3498 on_update,
3499 deferrable,
3500 } if columns == &["route_id", "schedule_id"]
3501 && name.as_deref() == Some("fk_trips_schedule")
3502 && ref_table == "schedules"
3503 && ref_columns == &["route_id", "schedule_id"]
3504 && on_delete.as_deref() == Some("CASCADE")
3505 && on_update.as_deref() == Some("RESTRICT")
3506 && deferrable.as_deref() == Some("DEFERRABLE INITIALLY DEFERRED")
3507 )),
3508 "multi-column foreign key should be represented in generated commands"
3509 );
3510
3511 let sql = add_fk_cmd.to_sql();
3512 assert!(
3513 sql.contains(
3514 "ALTER TABLE trips ADD CONSTRAINT fk_trips_schedule FOREIGN KEY (route_id, schedule_id) REFERENCES schedules(route_id, schedule_id) ON DELETE CASCADE ON UPDATE RESTRICT DEFERRABLE INITIALLY DEFERRED"
3515 ),
3516 "generated SQL should include composite foreign key, got: {sql}"
3517 );
3518 }
3519
3520 #[test]
3521 fn test_check_expr_sql_renders_integer_in_and_column_comparison() {
3522 assert_eq!(
3523 check_expr_to_sql(&CheckExpr::InIntegers {
3524 column: "duration_hours".to_string(),
3525 values: vec![8, 10, 12],
3526 }),
3527 "duration_hours IN (8, 10, 12)"
3528 );
3529
3530 assert_eq!(
3531 check_expr_to_sql(&CheckExpr::CompareColumns {
3532 left_column: "origin_harbor_id".to_string(),
3533 op: CheckComparisonOp::NotEqual,
3534 right_column: "destination_harbor_id".to_string(),
3535 }),
3536 "origin_harbor_id <> destination_harbor_id"
3537 );
3538
3539 assert_eq!(
3540 check_expr_to_sql(&CheckExpr::TextCompare {
3541 column: "module".to_string(),
3542 op: CheckComparisonOp::NotEqual,
3543 value: "charter".to_string(),
3544 }),
3545 "module <> 'charter'"
3546 );
3547
3548 assert_eq!(
3549 check_expr_to_sql(&CheckExpr::CompareColumnToCoalesce {
3550 left_column: "start_date".to_string(),
3551 op: CheckComparisonOp::LessOrEqual,
3552 coalesce_column: "end_date".to_string(),
3553 fallback: "2099-12-31".to_string(),
3554 fallback_cast: Some("date".to_string()),
3555 }),
3556 "start_date <= COALESCE(end_date, '2099-12-31'::date)"
3557 );
3558
3559 assert_eq!(
3560 check_expr_to_sql(&CheckExpr::LowerTrimEquals {
3561 column: "slug".to_string(),
3562 }),
3563 "slug = lower(btrim(slug))"
3564 );
3565 }
3566}