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