use super::collection::PostgresDDL;
use super::ddl::{Column, Enum, ForeignKey, Index, Table};
use heck::{ToPascalCase, ToSnakeCase};
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, Default)]
pub struct GeneratedSchema {
pub code: String,
pub enums: Vec<String>,
pub tables: Vec<String>,
pub indexes: Vec<String>,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, Default)]
pub struct CodegenOptions {
pub module_doc: Option<String>,
pub include_schema: bool,
pub schema_name: String,
pub use_pub: bool,
}
pub fn generate_rust_schema(ddl: &PostgresDDL, options: &CodegenOptions) -> GeneratedSchema {
let mut result = GeneratedSchema::default();
let mut code = String::new();
code.push_str("//! Auto-generated PostgreSQL schema from introspection\n");
code.push_str("//!\n");
if let Some(doc) = &options.module_doc {
for line in doc.lines() {
code.push_str("//! ");
code.push_str(line);
code.push('\n');
}
}
code.push('\n');
code.push_str("use drizzle::postgres::prelude::*;\n\n");
let mut enum_map: HashMap<(String, String), String> = HashMap::new();
for e in ddl.enums.list() {
let type_name = e.name.to_pascal_case();
enum_map.insert((e.schema.to_string(), e.name.to_string()), type_name);
}
for e in ddl.enums.list() {
let enum_code = generate_enum_struct(e, options.use_pub);
code.push_str(&enum_code);
code.push('\n');
result.enums.push(e.name.to_string());
}
let mut table_columns: HashMap<(String, String), Vec<&Column>> = HashMap::new();
for column in ddl.columns.list() {
table_columns
.entry((column.schema.to_string(), column.table.to_string()))
.or_default()
.push(column);
}
let mut table_pks: HashMap<(String, String), HashSet<String>> = HashMap::new();
for pk in ddl.pks.list() {
for col in pk.columns.iter() {
table_pks
.entry((pk.schema.to_string(), pk.table.to_string()))
.or_default()
.insert(col.to_string());
}
}
let mut table_uniques: HashMap<(String, String), HashSet<String>> = HashMap::new();
for unique in ddl.uniques.list() {
if unique.columns.len() == 1 {
table_uniques
.entry((unique.schema.to_string(), unique.table.to_string()))
.or_default()
.insert(unique.columns[0].to_string());
}
}
let mut fk_map: HashMap<(String, String, String), (&ForeignKey, usize)> = HashMap::new();
for fk in ddl.fks.list() {
for (idx, col) in fk.columns.iter().enumerate() {
fk_map.insert(
(fk.schema.to_string(), fk.table.to_string(), col.to_string()),
(fk, idx),
);
}
}
for table in ddl.tables.list() {
let key = (table.schema.to_string(), table.name.to_string());
let columns = table_columns.get(&key).map(|c| c.as_slice()).unwrap_or(&[]);
let pk_columns = table_pks.get(&key);
let unique_columns = table_uniques.get(&key);
let is_composite_pk = pk_columns.map(|pks| pks.len() > 1).unwrap_or(false);
let table_code = generate_table_struct(&TableGenContext {
table,
columns,
pk_columns,
unique_columns,
is_composite_pk,
fk_map: &fk_map,
enum_map: &enum_map,
use_pub: options.use_pub,
});
code.push_str(&table_code);
code.push('\n');
result.tables.push(table.name.to_string());
}
for index in ddl.indexes.list() {
let index_code = generate_index_struct(index, options.use_pub);
code.push_str(&index_code);
code.push('\n');
result.indexes.push(index.name.to_string());
}
if options.include_schema {
let schema_code = generate_schema_struct(
&options.schema_name,
&result.tables,
&result.indexes,
options.use_pub,
);
code.push_str(&schema_code);
}
result.code = code;
result
}
struct TableGenContext<'a> {
table: &'a Table,
columns: &'a [&'a Column],
pk_columns: Option<&'a HashSet<String>>,
unique_columns: Option<&'a HashSet<String>>,
is_composite_pk: bool,
fk_map: &'a HashMap<(String, String, String), (&'a ForeignKey, usize)>,
enum_map: &'a HashMap<(String, String), String>,
use_pub: bool,
}
fn generate_table_struct(ctx: &TableGenContext<'_>) -> String {
let struct_name = ctx.table.name.to_pascal_case();
let vis = if ctx.use_pub { "pub " } else { "" };
let mut code = String::new();
code.push_str("#[PostgresTable]\n");
code.push_str(&format!("{vis}struct {struct_name} {{\n"));
let mut sorted_columns: Vec<&&Column> = ctx.columns.iter().collect();
sorted_columns.sort_by(|a, b| {
let ao = a.ordinal_position.unwrap_or(i32::MAX);
let bo = b.ordinal_position.unwrap_or(i32::MAX);
ao.cmp(&bo).then_with(|| a.name.cmp(&b.name))
});
for column in sorted_columns {
let field_code = generate_column_field(
column,
ctx.pk_columns,
ctx.unique_columns,
ctx.is_composite_pk,
ctx.fk_map,
ctx.enum_map,
ctx.use_pub,
);
code.push_str(&field_code);
}
code.push_str("}\n");
code
}
fn generate_column_field(
column: &Column,
pk_columns: Option<&HashSet<String>>,
unique_columns: Option<&HashSet<String>>,
is_composite_pk: bool,
fk_map: &HashMap<(String, String, String), (&ForeignKey, usize)>,
enum_map: &HashMap<(String, String), String>,
use_pub: bool,
) -> String {
let field_name = column.name.to_snake_case();
let vis = if use_pub { "pub " } else { "" };
let col_name_str = column.name.to_string();
let is_pk = pk_columns
.map(|pks| pks.contains(&col_name_str))
.unwrap_or(false);
let is_unique = unique_columns
.map(|uqs| uqs.contains(&col_name_str))
.unwrap_or(false);
let should_add_primary = is_pk && !is_composite_pk;
let is_serial = column
.default
.as_ref()
.map(|d| d.contains("nextval"))
.unwrap_or(false)
&& column.identity.is_none();
let fk_info = fk_map.get(&(
column.schema.to_string(),
column.table.to_string(),
col_name_str.clone(),
));
let type_schema = column.type_schema.as_deref().unwrap_or(&column.schema);
let enum_type = enum_map.get(&(type_schema.to_string(), column.sql_type.to_string()));
let mut attrs = Vec::new();
if is_serial {
attrs.push("serial".to_string());
}
if let Some(identity) = &column.identity {
use super::ddl::IdentityType;
let identity_type = match identity.type_ {
IdentityType::Always => "always",
IdentityType::ByDefault => "by_default",
};
let mut seq_opts: Vec<String> = Vec::new();
if let Some(increment) = &identity.increment
&& increment != "1"
{
seq_opts.push(format!("increment = {}", increment));
}
if let Some(start) = &identity.start_with
&& start != "1"
{
seq_opts.push(format!("start = {}", start));
}
if let Some(min) = &identity.min_value {
seq_opts.push(format!("min_value = {}", min));
}
if let Some(max) = &identity.max_value {
seq_opts.push(format!("max_value = {}", max));
}
if let Some(cache) = &identity.cache
&& *cache != 1
{
seq_opts.push(format!("cache = {}", cache));
}
if identity.cycle == Some(true) {
seq_opts.push("cycle".to_string());
}
if seq_opts.is_empty() {
attrs.push(format!("identity({})", identity_type));
} else {
attrs.push(format!(
"identity({}, {})",
identity_type,
seq_opts.join(", ")
));
}
}
if should_add_primary {
attrs.push("primary".to_string());
}
if is_unique {
attrs.push("unique".to_string());
}
if enum_type.is_some() {
attrs.push("enum".to_string());
}
if let Some(generated) = &column.generated {
use super::ddl::GeneratedType;
let gen_type = match generated.gen_type {
GeneratedType::Stored => "stored",
};
let expr = generated.expression.replace('"', "\\\"");
attrs.push(format!("generated({}, \"{}\")", gen_type, expr));
}
if let Some(default) = &column.default
&& !is_serial
&& column.generated.is_none()
&& let Some(formatted) = format_default_value(default, &column.sql_type)
{
attrs.push(format!("default = {formatted}"));
}
if let Some((fk, idx)) = fk_info {
let ref_table = fk.table_to.to_pascal_case();
let ref_column = fk.columns_to.get(*idx).cloned().unwrap_or_default();
attrs.push(format!("references = {ref_table}::{ref_column}"));
if let Some(on_delete) = &fk.on_delete
&& on_delete != "NO ACTION"
{
let action = on_delete.to_lowercase().replace(' ', "_");
attrs.push(format!("on_delete = {action}"));
}
if let Some(on_update) = &fk.on_update
&& on_update != "NO ACTION"
{
let action = on_update.to_lowercase().replace(' ', "_");
attrs.push(format!("on_update = {action}"));
}
}
let mut result = String::new();
if !attrs.is_empty() {
result.push_str(&format!(" #[column({})]\n", attrs.join(", ")));
}
let rust_type = if let Some(enum_name) = enum_type {
if column.not_null {
enum_name.clone()
} else {
format!("Option<{}>", enum_name)
}
} else {
sql_type_to_rust_type(&column.sql_type, column.not_null)
};
result.push_str(&format!(" {vis}{field_name}: {rust_type},\n"));
result
}
fn generate_enum_struct(e: &Enum, use_pub: bool) -> String {
let enum_name = e.name.to_pascal_case();
let vis = if use_pub { "pub " } else { "" };
let mut code = String::new();
code.push_str("#[derive(PostgresEnum, Default, Clone, PartialEq, Debug)]\n");
code.push_str(&format!("{vis}enum {enum_name} {{\n"));
for (idx, value) in e.values.iter().enumerate() {
let variant_name = value.to_pascal_case();
if idx == 0 {
code.push_str(" #[default]\n");
}
code.push_str(&format!(" {},\n", variant_name));
}
code.push_str("}\n");
code
}
fn format_default_value(default: &str, sql_type: &str) -> Option<String> {
let default = default.trim();
if default.contains('(') || default.starts_with("nextval") {
return None;
}
if default.eq_ignore_ascii_case("null") {
return None;
}
if default.eq_ignore_ascii_case("true") || default.eq_ignore_ascii_case("false") {
return Some(default.to_lowercase());
}
if sql_type.contains("int")
|| sql_type.contains("numeric")
|| sql_type.contains("decimal")
|| sql_type == "float4"
|| sql_type == "float8"
{
let value = default.split("::").next().unwrap_or(default);
return Some(value.trim_matches('\'').to_string());
}
if sql_type.contains("text")
|| sql_type.contains("varchar")
|| sql_type.contains("char")
|| sql_type == "bpchar"
{
let value = default.split("::").next().unwrap_or(default);
let trimmed = value.trim_matches('\'');
return Some(format!("\"{}\"", trimmed));
}
Some(default.to_string())
}
pub fn sql_type_to_rust_type(sql_type: &str, not_null: bool) -> String {
if let Some(elem) = sql_type.strip_prefix('_') {
let elem_ty = sql_type_to_rust_type(elem, true);
let base = format!("Vec<{}>", elem_ty);
return if not_null {
base
} else {
format!("Option<{}>", base)
};
}
let base_type = match sql_type {
s if s.eq_ignore_ascii_case("int2") || s.eq_ignore_ascii_case("smallint") => "i16",
s if s.eq_ignore_ascii_case("int4")
|| s.eq_ignore_ascii_case("integer")
|| s.eq_ignore_ascii_case("int") =>
{
"i32"
}
s if s.eq_ignore_ascii_case("int8") || s.eq_ignore_ascii_case("bigint") => "i64",
s if s.eq_ignore_ascii_case("serial") || s.eq_ignore_ascii_case("serial4") => "i32",
s if s.eq_ignore_ascii_case("bigserial") || s.eq_ignore_ascii_case("serial8") => "i64",
s if s.eq_ignore_ascii_case("smallserial") || s.eq_ignore_ascii_case("serial2") => "i16",
s if s.eq_ignore_ascii_case("float4") || s.eq_ignore_ascii_case("real") => "f32",
s if s.eq_ignore_ascii_case("float8") || s.eq_ignore_ascii_case("double precision") => {
"f64"
}
s if s.eq_ignore_ascii_case("numeric") || s.eq_ignore_ascii_case("decimal") => "String",
s if s.eq_ignore_ascii_case("bool") || s.eq_ignore_ascii_case("boolean") => "bool",
s if s.eq_ignore_ascii_case("text")
|| s.eq_ignore_ascii_case("varchar")
|| s.eq_ignore_ascii_case("char")
|| s.eq_ignore_ascii_case("bpchar")
|| s.eq_ignore_ascii_case("name") =>
{
"String"
}
s if s.eq_ignore_ascii_case("bytea") => "Vec<u8>",
s if s.eq_ignore_ascii_case("uuid") => "uuid::Uuid",
s if s.eq_ignore_ascii_case("date") => "chrono::NaiveDate",
s if s.eq_ignore_ascii_case("time") => "chrono::NaiveTime",
s if s.eq_ignore_ascii_case("timestamp") => "chrono::NaiveDateTime",
s if s.eq_ignore_ascii_case("timestamptz") => "chrono::DateTime<chrono::Utc>",
s if s.eq_ignore_ascii_case("json") || s.eq_ignore_ascii_case("jsonb") => {
"serde_json::Value"
}
_ => "String",
};
if not_null {
base_type.to_string()
} else {
format!("Option<{}>", base_type)
}
}
fn generate_index_struct(index: &Index, use_pub: bool) -> String {
let struct_name = index.name.to_pascal_case();
let table_name = index.table.to_pascal_case();
let vis = if use_pub { "pub " } else { "" };
let mut code = String::new();
let attrs = if index.is_unique {
"#[PostgresIndex(unique)]"
} else {
"#[PostgresIndex]"
};
code.push_str(&format!("{attrs}\n"));
let columns: Vec<String> = index
.columns
.iter()
.map(|c| {
if c.is_expression {
format!("\"{}\"", c.value) } else {
format!("{}::{}", table_name, c.value.to_snake_case())
}
})
.collect();
code.push_str(&format!(
"{vis}struct {struct_name}({});\n",
columns.join(", ")
));
code
}
fn generate_schema_struct(
schema_name: &str,
tables: &[String],
indexes: &[String],
use_pub: bool,
) -> String {
let vis = if use_pub { "pub " } else { "" };
let mut code = String::new();
code.push_str("#[derive(PostgresSchema)]\n");
code.push_str(&format!("{vis}struct {schema_name} {{\n"));
for table in tables {
let field_name = table.to_snake_case();
let type_name = table.to_pascal_case();
code.push_str(&format!(" {vis}{field_name}: {type_name},\n"));
}
if !indexes.is_empty() {
code.push_str(" // Indexes:\n");
for index in indexes {
let field_name = index.to_snake_case();
let type_name = index.to_pascal_case();
code.push_str(&format!(" // {field_name}: {type_name},\n"));
}
}
code.push_str("}\n");
code
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sql_type_to_rust_type() {
assert_eq!(sql_type_to_rust_type("int4", true), "i32");
assert_eq!(sql_type_to_rust_type("int8", true), "i64");
assert_eq!(sql_type_to_rust_type("text", true), "String");
assert_eq!(sql_type_to_rust_type("bool", true), "bool");
assert_eq!(sql_type_to_rust_type("bytea", true), "Vec<u8>");
assert_eq!(sql_type_to_rust_type("int4", false), "Option<i32>");
assert_eq!(sql_type_to_rust_type("text", false), "Option<String>");
}
#[test]
fn test_format_default_value() {
assert_eq!(format_default_value("42", "int4"), Some("42".to_string()));
assert_eq!(
format_default_value("3.14::numeric", "numeric"),
Some("3.14".to_string())
);
assert_eq!(
format_default_value("true", "bool"),
Some("true".to_string())
);
assert_eq!(
format_default_value("'hello'::text", "text"),
Some("\"hello\"".to_string())
);
assert_eq!(format_default_value("now()", "timestamp"), None);
assert_eq!(
format_default_value("nextval('seq'::regclass)", "int4"),
None
);
}
}