1use super::types::ColumnType;
18use std::collections::HashMap;
19
20#[derive(Debug, Clone, Default)]
22pub struct Schema {
23 pub tables: HashMap<String, Table>,
24 pub indexes: Vec<Index>,
25 pub migrations: Vec<MigrationHint>,
26}
27
28#[derive(Debug, Clone)]
29pub struct Table {
30 pub name: String,
31 pub columns: Vec<Column>,
32}
33
34#[derive(Debug, Clone)]
36pub struct Column {
37 pub name: String,
38 pub data_type: ColumnType,
39 pub nullable: bool,
40 pub primary_key: bool,
41 pub unique: bool,
42 pub default: Option<String>,
43 pub foreign_key: Option<ForeignKey>,
44 pub check: Option<CheckConstraint>,
46 pub generated: Option<Generated>,
48}
49
50#[derive(Debug, Clone)]
52pub struct ForeignKey {
53 pub table: String,
54 pub column: String,
55 pub on_delete: FkAction,
56 pub on_update: FkAction,
57 pub deferrable: Deferrable,
59}
60
61#[derive(Debug, Clone, Default, PartialEq)]
63pub enum FkAction {
64 #[default]
65 NoAction,
66 Cascade,
67 SetNull,
68 SetDefault,
69 Restrict,
70}
71
72#[derive(Debug, Clone)]
73pub struct Index {
74 pub name: String,
75 pub table: String,
76 pub columns: Vec<String>,
77 pub unique: bool,
78 pub method: IndexMethod,
80 pub where_clause: Option<CheckExpr>,
82 pub include: Vec<String>,
84 pub concurrently: bool,
86}
87
88#[derive(Debug, Clone)]
89pub enum MigrationHint {
90 Rename { from: String, to: String },
92 Transform { expression: String, target: String },
94 Drop { target: String, confirmed: bool },
96}
97
98#[derive(Debug, Clone)]
104pub enum CheckExpr {
105 GreaterThan { column: String, value: i64 },
107 GreaterOrEqual { column: String, value: i64 },
109 LessThan { column: String, value: i64 },
111 LessOrEqual { column: String, value: i64 },
113 Between { column: String, low: i64, high: i64 },
114 In { column: String, values: Vec<String> },
115 Regex { column: String, pattern: String },
117 MaxLength { column: String, max: usize },
119 MinLength { column: String, min: usize },
121 NotNull { column: String },
122 And(Box<CheckExpr>, Box<CheckExpr>),
123 Or(Box<CheckExpr>, Box<CheckExpr>),
124 Not(Box<CheckExpr>),
125}
126
127#[derive(Debug, Clone)]
129pub struct CheckConstraint {
130 pub expr: CheckExpr,
131 pub name: Option<String>,
132}
133
134#[derive(Debug, Clone, Default, PartialEq)]
140pub enum Deferrable {
141 #[default]
142 NotDeferrable,
143 Deferrable,
144 InitiallyDeferred,
145 InitiallyImmediate,
146}
147
148#[derive(Debug, Clone)]
154pub enum Generated {
155 AlwaysStored(String),
157 AlwaysIdentity,
159 ByDefaultIdentity,
161}
162
163#[derive(Debug, Clone, Default, PartialEq)]
169pub enum IndexMethod {
170 #[default]
171 BTree,
172 Hash,
173 Gin,
174 Gist,
175 Brin,
176 SpGist,
177}
178
179impl Schema {
180 pub fn new() -> Self {
181 Self::default()
182 }
183
184 pub fn add_table(&mut self, table: Table) {
185 self.tables.insert(table.name.clone(), table);
186 }
187
188 pub fn add_index(&mut self, index: Index) {
189 self.indexes.push(index);
190 }
191
192 pub fn add_hint(&mut self, hint: MigrationHint) {
193 self.migrations.push(hint);
194 }
195
196 pub fn validate(&self) -> Result<(), Vec<String>> {
198 let mut errors = Vec::new();
199
200 for table in self.tables.values() {
201 for col in &table.columns {
202 if let Some(ref fk) = col.foreign_key {
203 if !self.tables.contains_key(&fk.table) {
204 errors.push(format!(
205 "FK error: {}.{} references non-existent table '{}'",
206 table.name, col.name, fk.table
207 ));
208 } else {
209 let ref_table = &self.tables[&fk.table];
210 if !ref_table.columns.iter().any(|c| c.name == fk.column) {
211 errors.push(format!(
212 "FK error: {}.{} references non-existent column '{}.{}'",
213 table.name, col.name, fk.table, fk.column
214 ));
215 }
216 }
217 }
218 }
219 }
220
221 if errors.is_empty() {
222 Ok(())
223 } else {
224 Err(errors)
225 }
226 }
227}
228
229impl Table {
230 pub fn new(name: impl Into<String>) -> Self {
231 Self {
232 name: name.into(),
233 columns: Vec::new(),
234 }
235 }
236
237 pub fn column(mut self, col: Column) -> Self {
238 self.columns.push(col);
239 self
240 }
241}
242
243impl Column {
244 pub fn new(name: impl Into<String>, data_type: ColumnType) -> Self {
246 Self {
247 name: name.into(),
248 data_type,
249 nullable: true,
250 primary_key: false,
251 unique: false,
252 default: None,
253 foreign_key: None,
254 check: None,
255 generated: None,
256 }
257 }
258
259 pub fn not_null(mut self) -> Self {
260 self.nullable = false;
261 self
262 }
263
264 pub fn primary_key(mut self) -> Self {
268 if !self.data_type.can_be_primary_key() {
269 panic!(
270 "Column '{}' of type {} cannot be a primary key. \
271 Valid PK types: UUID, SERIAL, BIGSERIAL, INT, BIGINT",
272 self.name,
273 self.data_type.name()
274 );
275 }
276 self.primary_key = true;
277 self.nullable = false;
278 self
279 }
280
281 pub fn unique(mut self) -> Self {
284 if !self.data_type.supports_indexing() {
285 panic!(
286 "Column '{}' of type {} cannot have UNIQUE constraint. \
287 JSONB and BYTEA types do not support standard indexing.",
288 self.name,
289 self.data_type.name()
290 );
291 }
292 self.unique = true;
293 self
294 }
295
296 pub fn default(mut self, val: impl Into<String>) -> Self {
297 self.default = Some(val.into());
298 self
299 }
300
301 pub fn references(mut self, table: &str, column: &str) -> Self {
309 self.foreign_key = Some(ForeignKey {
310 table: table.to_string(),
311 column: column.to_string(),
312 on_delete: FkAction::default(),
313 on_update: FkAction::default(),
314 deferrable: Deferrable::default(),
315 });
316 self
317 }
318
319 pub fn on_delete(mut self, action: FkAction) -> Self {
321 if let Some(ref mut fk) = self.foreign_key {
322 fk.on_delete = action;
323 }
324 self
325 }
326
327 pub fn on_update(mut self, action: FkAction) -> Self {
329 if let Some(ref mut fk) = self.foreign_key {
330 fk.on_update = action;
331 }
332 self
333 }
334
335 pub fn check(mut self, expr: CheckExpr) -> Self {
339 self.check = Some(CheckConstraint { expr, name: None });
340 self
341 }
342
343 pub fn check_named(mut self, name: impl Into<String>, expr: CheckExpr) -> Self {
345 self.check = Some(CheckConstraint {
346 expr,
347 name: Some(name.into()),
348 });
349 self
350 }
351
352 pub fn deferrable(mut self) -> Self {
356 if let Some(ref mut fk) = self.foreign_key {
357 fk.deferrable = Deferrable::Deferrable;
358 }
359 self
360 }
361
362 pub fn initially_deferred(mut self) -> Self {
364 if let Some(ref mut fk) = self.foreign_key {
365 fk.deferrable = Deferrable::InitiallyDeferred;
366 }
367 self
368 }
369
370 pub fn initially_immediate(mut self) -> Self {
372 if let Some(ref mut fk) = self.foreign_key {
373 fk.deferrable = Deferrable::InitiallyImmediate;
374 }
375 self
376 }
377
378 pub fn generated_stored(mut self, expr: impl Into<String>) -> Self {
382 self.generated = Some(Generated::AlwaysStored(expr.into()));
383 self
384 }
385
386 pub fn generated_identity(mut self) -> Self {
388 self.generated = Some(Generated::AlwaysIdentity);
389 self
390 }
391
392 pub fn generated_by_default(mut self) -> Self {
394 self.generated = Some(Generated::ByDefaultIdentity);
395 self
396 }
397}
398
399impl Index {
400 pub fn new(name: impl Into<String>, table: impl Into<String>, columns: Vec<String>) -> Self {
401 Self {
402 name: name.into(),
403 table: table.into(),
404 columns,
405 unique: false,
406 method: IndexMethod::default(),
407 where_clause: None,
408 include: Vec::new(),
409 concurrently: false,
410 }
411 }
412
413 pub fn unique(mut self) -> Self {
414 self.unique = true;
415 self
416 }
417
418 pub fn using(mut self, method: IndexMethod) -> Self {
422 self.method = method;
423 self
424 }
425
426 pub fn partial(mut self, expr: CheckExpr) -> Self {
428 self.where_clause = Some(expr);
429 self
430 }
431
432 pub fn include(mut self, cols: Vec<String>) -> Self {
434 self.include = cols;
435 self
436 }
437
438 pub fn concurrently(mut self) -> Self {
440 self.concurrently = true;
441 self
442 }
443}
444
445pub fn to_qail_string(schema: &Schema) -> String {
447 let mut output = String::new();
448 output.push_str("# QAIL Schema\n\n");
449
450 for table in schema.tables.values() {
451 output.push_str(&format!("table {} {{\n", table.name));
452 for col in &table.columns {
453 let mut constraints: Vec<String> = Vec::new();
454 if col.primary_key {
455 constraints.push("primary_key".to_string());
456 }
457 if !col.nullable && !col.primary_key {
458 constraints.push("not_null".to_string());
459 }
460 if col.unique {
461 constraints.push("unique".to_string());
462 }
463 if let Some(def) = &col.default {
464 constraints.push(format!("default {}", def));
465 }
466 if let Some(ref fk) = col.foreign_key {
467 constraints.push(format!("references {}({})", fk.table, fk.column));
468 }
469
470 let constraint_str = if constraints.is_empty() {
471 String::new()
472 } else {
473 format!(" {}", constraints.join(" "))
474 };
475
476 output.push_str(&format!(
477 " {} {}{}\n",
478 col.name,
479 col.data_type.to_pg_type(),
480 constraint_str
481 ));
482 }
483 output.push_str("}\n\n");
484 }
485
486 for idx in &schema.indexes {
487 let unique = if idx.unique { "unique " } else { "" };
488 output.push_str(&format!(
489 "{}index {} on {} ({})\n",
490 unique,
491 idx.name,
492 idx.table,
493 idx.columns.join(", ")
494 ));
495 }
496
497 for hint in &schema.migrations {
498 match hint {
499 MigrationHint::Rename { from, to } => {
500 output.push_str(&format!("rename {} -> {}\n", from, to));
501 }
502 MigrationHint::Transform { expression, target } => {
503 output.push_str(&format!("transform {} -> {}\n", expression, target));
504 }
505 MigrationHint::Drop { target, confirmed } => {
506 let confirm = if *confirmed { " confirm" } else { "" };
507 output.push_str(&format!("drop {}{}\n", target, confirm));
508 }
509 }
510 }
511
512 output
513}
514
515pub fn schema_to_commands(schema: &Schema) -> Vec<crate::ast::Qail> {
518 use crate::ast::{Action, Constraint, Expr, IndexDef, Qail};
519
520 let mut cmds = Vec::new();
521
522 let mut table_order: Vec<&Table> = schema.tables.values().collect();
524 table_order.sort_by(|a, b| {
525 let a_has_fk = a.columns.iter().any(|c| c.foreign_key.is_some());
526 let b_has_fk = b.columns.iter().any(|c| c.foreign_key.is_some());
527 a_has_fk.cmp(&b_has_fk)
528 });
529
530 for table in table_order {
531 let columns: Vec<Expr> = table.columns.iter().map(|col| {
533 let mut constraints = Vec::new();
534
535 if col.primary_key {
536 constraints.push(Constraint::PrimaryKey);
537 }
538 if col.nullable {
539 constraints.push(Constraint::Nullable);
540 }
541 if col.unique {
542 constraints.push(Constraint::Unique);
543 }
544 if let Some(def) = &col.default {
545 constraints.push(Constraint::Default(def.clone()));
546 }
547 if let Some(ref fk) = col.foreign_key {
548 constraints.push(Constraint::References(format!(
549 "{}({})",
550 fk.table, fk.column
551 )));
552 }
553
554 Expr::Def {
555 name: col.name.clone(),
556 data_type: col.data_type.to_pg_type(),
557 constraints,
558 }
559 }).collect();
560
561 cmds.push(Qail {
562 action: Action::Make,
563 table: table.name.clone(),
564 columns,
565 ..Default::default()
566 });
567 }
568
569 for idx in &schema.indexes {
571 cmds.push(Qail {
572 action: Action::Index,
573 table: String::new(),
574 index_def: Some(IndexDef {
575 name: idx.name.clone(),
576 table: idx.table.clone(),
577 columns: idx.columns.clone(),
578 unique: idx.unique,
579 index_type: None,
580 }),
581 ..Default::default()
582 });
583 }
584
585 cmds
586}
587
588#[cfg(test)]
589mod tests {
590 use super::*;
591
592 #[test]
593 fn test_schema_builder() {
594 let mut schema = Schema::new();
595
596 let users = Table::new("users")
597 .column(Column::new("id", ColumnType::Serial).primary_key())
598 .column(Column::new("name", ColumnType::Text).not_null())
599 .column(Column::new("email", ColumnType::Text).unique());
600
601 schema.add_table(users);
602 schema.add_index(Index::new("idx_users_email", "users", vec!["email".into()]).unique());
603
604 let output = to_qail_string(&schema);
605 assert!(output.contains("table users"));
606 assert!(output.contains("id SERIAL primary_key"));
607 assert!(output.contains("unique index idx_users_email"));
608 }
609
610 #[test]
611 fn test_migration_hints() {
612 let mut schema = Schema::new();
613 schema.add_hint(MigrationHint::Rename {
614 from: "users.username".into(),
615 to: "users.name".into(),
616 });
617
618 let output = to_qail_string(&schema);
619 assert!(output.contains("rename users.username -> users.name"));
620 }
621
622 #[test]
623 #[should_panic(expected = "cannot be a primary key")]
624 fn test_invalid_primary_key_type() {
625 Column::new("data", ColumnType::Text).primary_key();
627 }
628
629 #[test]
630 #[should_panic(expected = "cannot have UNIQUE")]
631 fn test_invalid_unique_type() {
632 Column::new("data", ColumnType::Jsonb).unique();
634 }
635
636 #[test]
637 fn test_foreign_key_valid() {
638 let mut schema = Schema::new();
639
640 schema.add_table(
641 Table::new("users").column(Column::new("id", ColumnType::Uuid).primary_key()),
642 );
643
644 schema.add_table(
645 Table::new("posts")
646 .column(Column::new("id", ColumnType::Uuid).primary_key())
647 .column(
648 Column::new("user_id", ColumnType::Uuid)
649 .references("users", "id")
650 .on_delete(FkAction::Cascade),
651 ),
652 );
653
654 assert!(schema.validate().is_ok());
656 }
657
658 #[test]
659 fn test_foreign_key_invalid_table() {
660 let mut schema = Schema::new();
661
662 schema.add_table(
663 Table::new("posts")
664 .column(Column::new("id", ColumnType::Uuid).primary_key())
665 .column(Column::new("user_id", ColumnType::Uuid).references("nonexistent", "id")),
666 );
667
668 let result = schema.validate();
670 assert!(result.is_err());
671 assert!(result.unwrap_err()[0].contains("non-existent table"));
672 }
673
674 #[test]
675 fn test_foreign_key_invalid_column() {
676 let mut schema = Schema::new();
677
678 schema.add_table(
679 Table::new("users").column(Column::new("id", ColumnType::Uuid).primary_key()),
680 );
681
682 schema.add_table(
683 Table::new("posts")
684 .column(Column::new("id", ColumnType::Uuid).primary_key())
685 .column(
686 Column::new("user_id", ColumnType::Uuid).references("users", "wrong_column"),
687 ),
688 );
689
690 let result = schema.validate();
692 assert!(result.is_err());
693 assert!(result.unwrap_err()[0].contains("non-existent column"));
694 }
695}