1use apiplant_core::schema::Field;
10use apiplant_core::{App, FieldType, Resource};
11use sea_orm::{ConnectionTrait, DatabaseBackend, Statement};
12use std::collections::HashSet;
13
14use crate::ident::quote_ident;
15use crate::Error;
16
17pub async fn migrate(conn: &impl ConnectionTrait, app: &App) -> Result<(), Error> {
23 for resource in app.resources_in_dependency_order() {
24 create_table_if_absent(conn, resource).await?;
25 add_missing_columns(conn, resource).await?;
26 }
27 for resource in app.resources_in_dependency_order() {
28 add_foreign_keys(conn, resource, app).await?;
29 }
30 Ok(())
31}
32
33async fn add_foreign_keys(
35 conn: &impl ConnectionTrait,
36 r: &Resource,
37 app: &App,
38) -> Result<(), Error> {
39 let table = quote_ident(&r.table_name())?;
40 for reference in r.references() {
41 let Some(target) = app.resources.get(&reference.target) else {
42 tracing::warn!(
43 resource = %r.meta.name,
44 field = %reference.field,
45 target = %reference.target,
46 "reference points at an unknown resource; skipping FK"
47 );
48 continue;
49 };
50 let constraint = format!("fk_{}_{}", r.table_name(), reference.field);
52 if constraint_exists(conn, &constraint).await? {
53 continue;
54 }
55 let sql = format!(
56 "ALTER TABLE {table} ADD CONSTRAINT {con} \
57 FOREIGN KEY ({col}) REFERENCES {target_tbl}(\"id\") ON DELETE {action}",
58 con = quote_ident(&constraint)?,
59 col = quote_ident(&reference.field)?,
60 target_tbl = quote_ident(&target.table_name())?,
61 action = reference.on_delete.to_sql(),
62 );
63 conn.execute(Statement::from_string(DatabaseBackend::Postgres, sql))
64 .await?;
65 tracing::info!(
66 resource = %r.meta.name,
67 field = %reference.field,
68 target = %reference.target,
69 "migrated: added foreign key"
70 );
71 }
72 Ok(())
73}
74
75async fn constraint_exists(conn: &impl ConnectionTrait, name: &str) -> Result<bool, Error> {
76 let stmt = Statement::from_sql_and_values(
77 DatabaseBackend::Postgres,
78 "SELECT 1 FROM pg_constraint WHERE conname = $1 LIMIT 1",
79 [name.into()],
80 );
81 Ok(conn.query_one(stmt).await?.is_some())
82}
83
84fn column_type(field: &Field) -> String {
86 match field.ty {
87 FieldType::String => match field.max_length {
88 Some(n) => format!("varchar({n})"),
89 None => "varchar".to_string(),
90 },
91 FieldType::File => format!("varchar({})", field.max_length.unwrap_or(1024)),
94 FieldType::Text => "text".to_string(),
95 FieldType::Integer => "integer".to_string(),
96 FieldType::BigInt => "bigint".to_string(),
97 FieldType::Float => "double precision".to_string(),
98 FieldType::Boolean => "boolean".to_string(),
99 FieldType::Uuid | FieldType::Reference => "uuid".to_string(),
100 FieldType::Timestamp => "timestamptz".to_string(),
101 FieldType::Json => "jsonb".to_string(),
102 }
103}
104
105fn default_clause(field: &Field) -> String {
107 let Some(v) = &field.default else {
108 return String::new();
109 };
110 match v {
111 serde_json::Value::Bool(b) => format!(" DEFAULT {b}"),
112 serde_json::Value::Number(n) => format!(" DEFAULT {n}"),
113 serde_json::Value::String(s) => format!(" DEFAULT '{}'", s.replace('\'', "''")),
114 _ => String::new(),
115 }
116}
117
118async fn create_table_if_absent(conn: &impl ConnectionTrait, r: &Resource) -> Result<(), Error> {
119 let table = quote_ident(&r.table_name())?;
120 let mut cols: Vec<String> = vec![format!(
121 "{} uuid PRIMARY KEY DEFAULT gen_random_uuid()",
122 quote_ident("id")?
123 )];
124
125 for (name, field) in &r.fields {
126 let mut col = format!("{} {}", quote_ident(name)?, column_type(field));
127 col.push_str(&default_clause(field));
128 if field.required {
129 col.push_str(" NOT NULL");
130 }
131 if field.unique {
132 col.push_str(" UNIQUE");
133 }
134 cols.push(col);
135 }
136
137 if r.meta.timestamps {
138 cols.push(format!(
139 "{} timestamptz NOT NULL DEFAULT now()",
140 quote_ident("created_at")?
141 ));
142 cols.push(format!(
143 "{} timestamptz NOT NULL DEFAULT now()",
144 quote_ident("updated_at")?
145 ));
146 }
147
148 let sql = format!("CREATE TABLE IF NOT EXISTS {table} ({})", cols.join(", "));
149 conn.execute(Statement::from_string(DatabaseBackend::Postgres, sql))
150 .await?;
151 tracing::debug!(table = %r.table_name(), "ensured table");
152 Ok(())
153}
154
155async fn add_missing_columns(conn: &impl ConnectionTrait, r: &Resource) -> Result<(), Error> {
156 let existing = existing_columns(conn, &r.table_name()).await?;
157 let table = quote_ident(&r.table_name())?;
158
159 for (name, field) in &r.fields {
160 if existing.contains(name.as_str()) {
161 continue;
162 }
163 let sql = format!(
166 "ALTER TABLE {table} ADD COLUMN IF NOT EXISTS {} {}{}",
167 quote_ident(name)?,
168 column_type(field),
169 default_clause(field),
170 );
171 conn.execute(Statement::from_string(DatabaseBackend::Postgres, sql))
172 .await?;
173 tracing::info!(table = %r.table_name(), column = %name, "migrated: added column");
174 }
175 Ok(())
176}
177
178async fn existing_columns(
179 conn: &impl ConnectionTrait,
180 table: &str,
181) -> Result<HashSet<String>, Error> {
182 let stmt = Statement::from_sql_and_values(
183 DatabaseBackend::Postgres,
184 "SELECT column_name FROM information_schema.columns \
185 WHERE table_schema = current_schema() AND table_name = $1",
186 [table.into()],
187 );
188 let rows = conn.query_all(stmt).await?;
189 let mut set = HashSet::new();
190 for row in rows {
191 set.insert(row.try_get::<String>("", "column_name")?);
192 }
193 Ok(set)
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199 use apiplant_core::schema::OnDelete;
200
201 fn field(ty: FieldType) -> Field {
202 Field {
203 ty,
204 references: None,
205 required: false,
206 unique: false,
207 hidden: false,
208 default: None,
209 max_length: None,
210 on_delete: Some(OnDelete::Restrict),
211 admin: Default::default(),
212 }
213 }
214
215 #[test]
216 fn column_type_honours_max_length_and_json_types() {
217 let mut string = field(FieldType::String);
218 string.max_length = Some(320);
219 assert_eq!(column_type(&string), "varchar(320)");
220
221 assert_eq!(column_type(&field(FieldType::Reference)), "uuid");
222 assert_eq!(column_type(&field(FieldType::Json)), "jsonb");
223 assert_eq!(column_type(&field(FieldType::Timestamp)), "timestamptz");
224 }
225
226 #[test]
227 fn default_clause_renders_scalars_and_escapes_strings() {
228 let mut text = field(FieldType::String);
229 text.default = Some(serde_json::json!("O'Hara"));
230 assert_eq!(default_clause(&text), " DEFAULT 'O''Hara'");
231
232 let mut number = field(FieldType::Integer);
233 number.default = Some(serde_json::json!(42));
234 assert_eq!(default_clause(&number), " DEFAULT 42");
235
236 let mut structured = field(FieldType::Json);
237 structured.default = Some(serde_json::json!({ "nested": true }));
238 assert_eq!(default_clause(&structured), "");
239 }
240}