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