use std::fmt::Write as _;
use crate::core::{FieldSchema, FieldType, ModelSchema, Relation};
use crate::sql::{Dialect, Postgres};
#[must_use]
pub fn create_table_sql(model: &ModelSchema) -> String {
create_table_sql_with_dialect(&Postgres, model)
}
#[must_use]
pub fn create_table_if_not_exists_sql(model: &ModelSchema) -> String {
create_table_if_not_exists_sql_with_dialect(&Postgres, model)
}
#[must_use]
pub fn drop_table_sql(model: &ModelSchema, if_exists: bool, cascade: bool) -> String {
drop_table_sql_with_dialect(&Postgres, model, if_exists, cascade)
}
#[must_use]
pub fn create_constraints_sql(model: &ModelSchema) -> Vec<String> {
create_constraints_sql_with_dialect(&Postgres, model)
}
#[must_use]
pub fn create_table_sql_with_dialect(dialect: &dyn Dialect, model: &ModelSchema) -> String {
let mut s = String::new();
s.push_str("CREATE TABLE ");
s.push_str(&dialect.quote_ident(model.table));
s.push_str(" (");
let mut first = true;
for field in model.scalar_fields() {
if !first {
s.push_str(", ");
}
first = false;
write_column_def(&mut s, dialect, field);
}
if dialect.inline_fks_in_create_table() {
for clause in inline_fk_clauses(dialect, model) {
s.push_str(", ");
s.push_str(&clause);
}
}
s.push(')');
if let Some(comment) = model.db_table_comment {
if let Some(inline) = dialect.write_inline_table_comment(comment) {
s.push_str(&inline);
}
}
s
}
#[must_use]
pub fn table_comment_statements_with_dialect(
dialect: &dyn Dialect,
model: &ModelSchema,
) -> Vec<String> {
let mut out = Vec::new();
if let Some(comment) = model.db_table_comment {
if let Some(stmt) = dialect.table_comment_statement(model.table, comment) {
out.push(stmt);
}
}
out
}
#[must_use]
pub fn create_table_if_not_exists_sql_with_dialect(
dialect: &dyn Dialect,
model: &ModelSchema,
) -> String {
let mut s = create_table_sql_with_dialect(dialect, model);
debug_assert!(s.starts_with("CREATE TABLE "));
s.replace_range(.."CREATE TABLE".len(), "CREATE TABLE IF NOT EXISTS");
s
}
#[must_use]
pub fn drop_table_sql_with_dialect(
dialect: &dyn Dialect,
model: &ModelSchema,
if_exists: bool,
cascade: bool,
) -> String {
let mut s = String::from("DROP TABLE ");
if if_exists {
s.push_str("IF EXISTS ");
}
s.push_str(&dialect.quote_ident(model.table));
if cascade {
s.push_str(" CASCADE");
}
s
}
#[must_use]
pub fn create_constraints_sql_with_dialect(
dialect: &dyn Dialect,
model: &ModelSchema,
) -> Vec<String> {
if dialect.inline_fks_in_create_table() {
return Vec::new();
}
let mut out = Vec::new();
for field in model.scalar_fields() {
let Some(rel) = field.relation else { continue };
let (to, on) = match rel {
Relation::Fk { to, on } | Relation::O2O { to, on } => (to, on),
};
let mut s = String::from("ALTER TABLE ");
s.push_str(&dialect.quote_ident(model.table));
s.push_str(" ADD CONSTRAINT ");
s.push_str(&dialect.quote_ident(&format!("{}_{}_fkey", model.table, field.column)));
s.push_str(" FOREIGN KEY (");
s.push_str(&dialect.quote_ident(field.column));
s.push_str(") REFERENCES ");
s.push_str(&dialect.quote_ident(to));
s.push_str(" (");
s.push_str(&dialect.quote_ident(on));
s.push(')');
if let Some(action) = field.fk_on_delete {
s.push_str(" ON DELETE ");
s.push_str(action.as_sql());
}
out.push(s);
}
for rel in model.composite_relations {
let mut s = String::from("ALTER TABLE ");
s.push_str(&dialect.quote_ident(model.table));
s.push_str(" ADD CONSTRAINT ");
s.push_str(&dialect.quote_ident(&format!("{}_{}_fkey", model.table, rel.name)));
s.push_str(" FOREIGN KEY (");
for (i, col) in rel.from.iter().enumerate() {
if i > 0 {
s.push_str(", ");
}
s.push_str(&dialect.quote_ident(col));
}
s.push_str(") REFERENCES ");
s.push_str(&dialect.quote_ident(rel.to));
s.push_str(" (");
for (i, col) in rel.on.iter().enumerate() {
if i > 0 {
s.push_str(", ");
}
s.push_str(&dialect.quote_ident(col));
}
s.push(')');
out.push(s);
}
out
}
fn inline_fk_clauses(dialect: &dyn Dialect, model: &ModelSchema) -> Vec<String> {
let mut out = Vec::new();
for field in model.scalar_fields() {
let Some(rel) = field.relation else { continue };
let (to, on) = match rel {
Relation::Fk { to, on } | Relation::O2O { to, on } => (to, on),
};
let mut s = String::from("CONSTRAINT ");
s.push_str(&dialect.quote_ident(&format!("{}_{}_fkey", model.table, field.column)));
s.push_str(" FOREIGN KEY (");
s.push_str(&dialect.quote_ident(field.column));
s.push_str(") REFERENCES ");
s.push_str(&dialect.quote_ident(to));
s.push_str(" (");
s.push_str(&dialect.quote_ident(on));
s.push(')');
if let Some(action) = field.fk_on_delete {
s.push_str(" ON DELETE ");
s.push_str(action.as_sql());
}
out.push(s);
}
for rel in model.composite_relations {
let mut s = String::from("CONSTRAINT ");
s.push_str(&dialect.quote_ident(&format!("{}_{}_fkey", model.table, rel.name)));
s.push_str(" FOREIGN KEY (");
for (i, col) in rel.from.iter().enumerate() {
if i > 0 {
s.push_str(", ");
}
s.push_str(&dialect.quote_ident(col));
}
s.push_str(") REFERENCES ");
s.push_str(&dialect.quote_ident(rel.to));
s.push_str(" (");
for (i, col) in rel.on.iter().enumerate() {
if i > 0 {
s.push_str(", ");
}
s.push_str(&dialect.quote_ident(col));
}
s.push(')');
out.push(s);
}
out
}
fn write_column_def(s: &mut String, dialect: &dyn Dialect, field: &FieldSchema) {
s.push_str(&dialect.quote_ident(field.column));
s.push(' ');
s.push_str(&sql_type(dialect, field));
if let Some(expr) = field.generated_as {
let _ = write!(s, " GENERATED ALWAYS AS ({expr}) STORED");
if !field.nullable {
s.push_str(" NOT NULL");
}
return;
}
if let Some(expr) = field.default {
let ty_name = crate::migrate::snapshot::field_type_name(field.ty);
let _ = write!(
s,
" DEFAULT {}",
dialect.translate_default_expr(expr, ty_name, field.max_length)
);
}
if !field.nullable {
s.push_str(" NOT NULL");
}
let serial_pk_inline = field.auto
&& matches!(field.ty, FieldType::I16 | FieldType::I32 | FieldType::I64)
&& dialect.serial_type_includes_primary_key();
if field.primary_key && !serial_pk_inline {
s.push_str(" PRIMARY KEY");
}
if field.unique && !field.primary_key {
s.push_str(" UNIQUE");
}
write_check_constraint(s, dialect, field);
if let Some(comment) = field.db_comment {
if let Some(inline) = dialect.write_inline_column_comment(comment) {
s.push_str(&inline);
}
}
}
#[must_use]
pub fn column_comment_statements_with_dialect(
dialect: &dyn Dialect,
model: &ModelSchema,
) -> Vec<String> {
let mut out = Vec::new();
for field in model.scalar_fields() {
let Some(comment) = field.db_comment else {
continue;
};
if let Some(stmt) = dialect.column_comment_statement(model.table, field.column, comment) {
out.push(stmt);
}
}
out
}
fn write_check_constraint(s: &mut String, dialect: &dyn Dialect, field: &FieldSchema) {
if field.min.is_none() && field.max.is_none() {
return;
}
s.push_str(" CHECK (");
let mut wrote = false;
if let Some(min) = field.min {
s.push_str(&dialect.quote_ident(field.column));
let _ = write!(s, " >= {min}");
wrote = true;
}
if let Some(max) = field.max {
if wrote {
s.push_str(" AND ");
}
s.push_str(&dialect.quote_ident(field.column));
let _ = write!(s, " <= {max}");
}
s.push(')');
}
fn sql_type(dialect: &dyn Dialect, field: &FieldSchema) -> String {
if field.auto && matches!(field.ty, FieldType::I16 | FieldType::I32 | FieldType::I64) {
return dialect.serial_type(field.ty).to_owned();
}
if field.case_insensitive && matches!(field.ty, FieldType::String) {
return dialect.ci_text_type(field.max_length);
}
dialect.column_type(field.ty, field.max_length)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::FieldType;
fn pg() -> Postgres {
Postgres
}
fn fld(
name: &'static str,
ty: FieldType,
auto: bool,
default: Option<&'static str>,
) -> FieldSchema {
FieldSchema {
name,
column: name,
ty,
nullable: false,
primary_key: false,
relation: None,
max_length: None,
min: None,
max: None,
default,
auto,
unique: false,
generated_as: None,
help_text: None,
choices: None,
db_comment: None,
verbose_name: None,
editable: true,
blank: false,
case_insensitive: false,
fk_on_delete: None,
validators: &[],
}
}
#[test]
fn auto_i32_emits_serial() {
let f = fld("id", FieldType::I32, true, None);
assert_eq!(sql_type(&pg(), &f), "SERIAL");
}
#[test]
fn auto_i64_emits_bigserial() {
let f = fld("id", FieldType::I64, true, None);
assert_eq!(sql_type(&pg(), &f), "BIGSERIAL");
}
#[test]
fn auto_datetime_emits_timestamptz_not_bigserial() {
let f = fld("created_at", FieldType::DateTime, true, Some("now()"));
assert_eq!(sql_type(&pg(), &f), "TIMESTAMPTZ");
}
#[test]
fn auto_uuid_emits_uuid_not_bigserial() {
let f = fld("id", FieldType::Uuid, true, Some("gen_random_uuid()"));
assert_eq!(sql_type(&pg(), &f), "UUID");
}
#[test]
fn full_create_table_has_single_default_per_column() {
let mut col_def = String::new();
write_column_def(
&mut col_def,
&pg(),
&fld("created_at", FieldType::DateTime, true, Some("now()")),
);
let n_defaults = col_def.matches(" DEFAULT ").count();
assert_eq!(
n_defaults, 1,
"expected exactly one DEFAULT clause, got {n_defaults} in: {col_def}"
);
assert!(col_def.contains("TIMESTAMPTZ"), "got: {col_def}");
assert!(col_def.contains("DEFAULT now()"), "got: {col_def}");
assert!(
!col_def.contains("BIGSERIAL"),
"must not emit BIGSERIAL: {col_def}"
);
}
#[test]
fn full_create_table_uuid_auto_has_single_default() {
let mut col_def = String::new();
write_column_def(
&mut col_def,
&pg(),
&fld("id", FieldType::Uuid, true, Some("gen_random_uuid()")),
);
let n_defaults = col_def.matches(" DEFAULT ").count();
assert_eq!(n_defaults, 1, "got: {col_def}");
assert!(col_def.contains("UUID"));
assert!(col_def.contains("DEFAULT gen_random_uuid()"));
}
#[test]
fn auto_i64_default_clause_passthrough() {
let mut col_def = String::new();
write_column_def(&mut col_def, &pg(), &fld("id", FieldType::I64, true, None));
assert!(col_def.contains("BIGSERIAL"), "got: {col_def}");
assert!(
!col_def.contains(" DEFAULT "),
"BIGSERIAL must not get an explicit DEFAULT: {col_def}"
);
}
#[cfg(feature = "sqlite")]
#[test]
fn sqlite_auto_pk_does_not_double_emit_primary_key() {
let dialect = crate::sql::Sqlite;
let mut col_def = String::new();
let mut field = fld("id", FieldType::I64, true, None);
field.primary_key = true;
write_column_def(&mut col_def, &dialect, &field);
let n_pk = col_def.matches(" PRIMARY KEY").count();
assert_eq!(
n_pk, 1,
"SQLite Auto PK should emit exactly one PRIMARY KEY token, got: {col_def}"
);
assert!(col_def.contains("AUTOINCREMENT"), "got: {col_def}");
}
#[cfg(feature = "sqlite")]
#[test]
fn sqlite_non_auto_pk_still_appends_primary_key() {
let dialect = crate::sql::Sqlite;
let mut col_def = String::new();
let mut field = fld("slug", FieldType::String, false, None);
field.primary_key = true;
write_column_def(&mut col_def, &dialect, &field);
assert!(col_def.contains(" PRIMARY KEY"), "got: {col_def}");
}
fn fk_model() -> ModelSchema {
let mut fk_field = fld("author_id", FieldType::I64, false, None);
fk_field.relation = Some(Relation::Fk {
to: "authors",
on: "id",
});
let id_field = {
let mut f = fld("id", FieldType::I64, true, None);
f.primary_key = true;
f
};
ModelSchema {
name: "Post",
table: "posts",
fields: Box::leak(Box::new([id_field, fk_field])),
display: None,
app_label: None,
admin: None,
soft_delete_column: None,
permissions: false,
audit_track: None,
m2m: &[],
indexes: &[],
check_constraints: &[],
exclusion_constraints: &[],
default_permissions: &[],
composite_relations: &[],
generic_relations: &[],
scope: crate::core::ModelScope::Tenant,
default_order: &[],
is_view: false,
verbose_name: None,
verbose_name_plural: None,
managed: true,
db_table_comment: None,
default_related_name: None,
base_manager_name: None,
required_db_vendor: None,
required_db_features: &[],
order_with_respect_to: None,
proxy: false,
get_latest_by: None,
extra_permissions: &[],
global_scopes: &[],
}
}
#[cfg(feature = "sqlite")]
#[test]
fn sqlite_inlines_fk_in_create_table() {
let model = fk_model();
let sql = create_table_sql_with_dialect(&crate::sql::Sqlite, &model);
assert!(
sql.contains(r#"CONSTRAINT "posts_author_id_fkey" FOREIGN KEY ("author_id") REFERENCES "authors" ("id")"#),
"expected inline FK clause; got: {sql}"
);
}
#[cfg(feature = "sqlite")]
#[test]
fn sqlite_returns_empty_post_hoc_constraint_list() {
let model = fk_model();
let post_hoc = create_constraints_sql_with_dialect(&crate::sql::Sqlite, &model);
assert!(
post_hoc.is_empty(),
"SQLite must return empty post-hoc constraint list (FKs are inline): {post_hoc:?}"
);
}
#[test]
fn postgres_keeps_post_hoc_alter_path() {
let model = fk_model();
let sql = create_table_sql_with_dialect(&crate::sql::Postgres, &model);
assert!(
!sql.contains("FOREIGN KEY"),
"PG CREATE TABLE must NOT contain inline FK: {sql}"
);
let post_hoc = create_constraints_sql_with_dialect(&crate::sql::Postgres, &model);
assert_eq!(post_hoc.len(), 1);
assert!(post_hoc[0].contains("ALTER TABLE"));
assert!(post_hoc[0].contains("ADD CONSTRAINT"));
assert!(post_hoc[0].contains(r#"REFERENCES "authors" ("id")"#));
}
#[cfg(feature = "mysql")]
#[test]
fn mysql_keeps_post_hoc_alter_path() {
let model = fk_model();
let sql = create_table_sql_with_dialect(&crate::sql::MySql, &model);
assert!(
!sql.contains("FOREIGN KEY"),
"MySQL CREATE TABLE must NOT contain inline FK: {sql}"
);
let post_hoc = create_constraints_sql_with_dialect(&crate::sql::MySql, &model);
assert_eq!(post_hoc.len(), 1);
assert!(post_hoc[0].contains("ALTER TABLE"));
assert!(post_hoc[0].contains("ADD CONSTRAINT"));
}
#[cfg(feature = "sqlite")]
#[test]
fn sqlite_inlines_fk_with_on_delete_cascade() {
let id_field = {
let mut f = fld("id", FieldType::I64, true, None);
f.primary_key = true;
f
};
let mut fk_field = fld("author_id", FieldType::I64, false, None);
fk_field.relation = Some(Relation::Fk {
to: "authors",
on: "id",
});
fk_field.fk_on_delete = Some(crate::core::OnDeleteAction::Cascade);
let model = ModelSchema {
fields: Box::leak(Box::new([id_field, fk_field])),
..fk_model()
};
let sql = create_table_sql_with_dialect(&crate::sql::Sqlite, &model);
assert!(
sql.contains("ON DELETE CASCADE"),
"expected inline ON DELETE CASCADE; got: {sql}"
);
}
}