use std::path::{Path, PathBuf};
use sqlx::{PgPool, Row, SqlitePool};
use umbral_casing::{pascal_case_from_table, to_snake_case};
use crate::migrate::{self, Column, MigrationFile, ModelMeta, Operation, Snapshot};
use crate::orm::SqlType;
pub const INSPECTED_PLUGIN_NAME: &str = migrate::APP_PLUGIN_NAME;
pub const INITIAL_MIGRATION_ID: &str = "0001_initial";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IntrospectedSchema {
pub tables: Vec<IntrospectedTable>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IntrospectedTable {
pub table: String,
pub name: String,
pub columns: Vec<IntrospectedColumn>,
pub unique_together: Vec<Vec<String>>,
pub indexes: Vec<Vec<String>>,
pub m2m: Vec<IntrospectedM2M>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IntrospectedM2M {
pub field_name: String,
pub target_table: String,
pub target_name: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IntrospectedColumn {
pub name: String,
pub ty: SqlType,
pub primary_key: bool,
pub nullable: bool,
pub fk_target: Option<String>,
pub unique: bool,
pub index: bool,
pub default: Option<String>,
pub auto_now_add: bool,
pub auto_now: bool,
pub choices: Vec<String>,
pub enum_type: Option<String>,
}
#[derive(Debug)]
pub enum InspectError {
Io(std::io::Error),
Json(serde_json::Error),
Sqlx(sqlx::Error),
NoTables,
UnsupportedColumnType {
table: String,
column: String,
sql_type: String,
},
Migrate(migrate::MigrateError),
}
impl std::fmt::Display for InspectError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
InspectError::Io(e) => write!(f, "umbral inspectdb: io: {e}"),
InspectError::Json(e) => write!(f, "umbral inspectdb: json: {e}"),
InspectError::Sqlx(e) => write!(f, "umbral inspectdb: sqlx: {e}"),
InspectError::NoTables => write!(
f,
"umbral inspectdb: no tables found in the database (nothing to import)"
),
InspectError::UnsupportedColumnType {
table,
column,
sql_type,
} => write!(
f,
"umbral inspectdb: column `{table}.{column}` has unsupported SQL type `{sql_type}`; \
add a matching SqlType variant or edit the generated model by hand"
),
InspectError::Migrate(e) => write!(f, "umbral inspectdb: migrate: {e}"),
}
}
}
impl std::error::Error for InspectError {}
impl From<std::io::Error> for InspectError {
fn from(e: std::io::Error) -> Self {
Self::Io(e)
}
}
impl From<sqlx::Error> for InspectError {
fn from(e: sqlx::Error) -> Self {
Self::Sqlx(e)
}
}
impl From<serde_json::Error> for InspectError {
fn from(e: serde_json::Error) -> Self {
Self::Json(e)
}
}
impl From<migrate::MigrateError> for InspectError {
fn from(e: migrate::MigrateError) -> Self {
Self::Migrate(e)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Framework {
Django,
Rails,
Laravel,
Prisma,
}
impl Framework {
pub fn parse(s: &str) -> Option<Framework> {
match s.trim().to_ascii_lowercase().as_str() {
"django" => Some(Framework::Django),
"rails" | "activerecord" => Some(Framework::Rails),
"laravel" | "eloquent" => Some(Framework::Laravel),
"prisma" | "typeorm" => Some(Framework::Prisma),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub struct InspectOptions {
pub source: Option<String>,
pub framework: Option<Framework>,
pub with_table_names: bool,
pub output: PathBuf,
pub mark_applied: bool,
}
#[derive(Debug, Clone, Default)]
pub struct InspectReport {
pub tables: usize,
pub columns: usize,
pub models_path: PathBuf,
pub migration_path: PathBuf,
}
pub async fn inspectdb(opts: InspectOptions) -> Result<InspectReport, InspectError> {
let schema = match &opts.source {
Some(url) => match crate::db::connect(url).await? {
crate::db::DbPool::Sqlite(pool) => introspect_pool(&pool).await?,
crate::db::DbPool::Postgres(pool) => introspect_pool_pg(&pool).await?,
},
None => match crate::db::pool_dispatched() {
crate::db::DbPool::Sqlite(pool) => introspect_pool(pool).await?,
crate::db::DbPool::Postgres(pool) => introspect_pool_pg(pool).await?,
},
};
if schema.tables.is_empty() {
return Err(InspectError::NoTables);
}
let mut schema = schema;
apply_recovered_conventions(&mut schema, opts.framework);
if let Some(fw) = opts.framework {
apply_framework_column_names(&mut schema, fw);
}
detect_m2m_relations(&mut schema, opts.framework, opts.with_table_names);
let models_src = render_models_with(&schema, opts.framework, opts.with_table_names);
let migration = render_initial_migration(&schema);
let report = write_outputs(&opts.output, &models_src, &migration).await?;
if opts.mark_applied {
let hash = migration.snapshot_after.hash();
migrate::record_applied(&migration.plugin, &migration.id, &hash).await?;
}
Ok(report)
}
pub async fn introspect_pool(pool: &SqlitePool) -> Result<IntrospectedSchema, InspectError> {
let table_rows = sqlx::query(
"SELECT name FROM sqlite_master \
WHERE type = 'table' \
AND name NOT LIKE 'sqlite_%' \
AND name <> 'umbral_migrations' \
ORDER BY name",
)
.fetch_all(pool)
.await?;
let mut tables: Vec<IntrospectedTable> = Vec::with_capacity(table_rows.len());
for row in table_rows {
let table: String = row.try_get("name")?;
let columns = introspect_columns(pool, &table).await?;
let (unique_together, indexes) = sqlite_composite_indexes(pool, &table).await?;
tables.push(IntrospectedTable {
name: pascal_case_from_table(&table),
table,
columns,
unique_together,
indexes,
m2m: Vec::new(),
});
}
Ok(IntrospectedSchema { tables })
}
pub async fn introspect_pool_pg(pool: &PgPool) -> Result<IntrospectedSchema, InspectError> {
let table_rows: Vec<(String,)> = sqlx::query_as(
"SELECT table_name FROM information_schema.tables \
WHERE table_schema = 'public' \
AND table_type = 'BASE TABLE' \
AND table_name <> 'umbral_migrations' \
ORDER BY table_name",
)
.fetch_all(pool)
.await?;
let mut tables: Vec<IntrospectedTable> = Vec::with_capacity(table_rows.len());
for (table,) in table_rows {
let columns = introspect_columns_pg(pool, &table).await?;
let (unique_together, indexes) = pg_composite_indexes(pool, &table).await;
tables.push(IntrospectedTable {
name: pascal_case_from_table(&table),
table,
columns,
unique_together,
indexes,
m2m: Vec::new(),
});
}
Ok(IntrospectedSchema { tables })
}
async fn introspect_columns_pg(
pool: &PgPool,
table: &str,
) -> Result<Vec<IntrospectedColumn>, InspectError> {
let pk_rows: Vec<(String,)> = sqlx::query_as(
"SELECT kcu.column_name \
FROM information_schema.table_constraints tc \
JOIN information_schema.key_column_usage kcu \
ON tc.constraint_name = kcu.constraint_name \
AND tc.table_schema = kcu.table_schema \
WHERE tc.constraint_type = 'PRIMARY KEY' \
AND tc.table_schema = 'public' \
AND tc.table_name = $1",
)
.bind(table)
.fetch_all(pool)
.await?;
let pk_columns: std::collections::HashSet<String> = pk_rows.into_iter().map(|(c,)| c).collect();
let column_rows: Vec<(String, String, String, String, Option<String>)> = sqlx::query_as(
"SELECT column_name, data_type, is_nullable, udt_name, column_default \
FROM information_schema.columns \
WHERE table_schema = 'public' AND table_name = $1 \
ORDER BY ordinal_position",
)
.bind(table)
.fetch_all(pool)
.await?;
let fk_rows: Vec<(String, String)> = sqlx::query_as(
"SELECT kcu.column_name, ccu.table_name AS foreign_table \
FROM information_schema.table_constraints tc \
JOIN information_schema.key_column_usage kcu \
ON tc.constraint_name = kcu.constraint_name \
AND tc.table_schema = kcu.table_schema \
JOIN information_schema.constraint_column_usage ccu \
ON ccu.constraint_name = tc.constraint_name \
AND ccu.table_schema = tc.table_schema \
WHERE tc.constraint_type = 'FOREIGN KEY' \
AND tc.table_schema = 'public' \
AND tc.table_name = $1",
)
.bind(table)
.fetch_all(pool)
.await?;
let fk_map: std::collections::HashMap<String, String> = fk_rows.into_iter().collect();
let idx_rows: Vec<(String, bool)> = sqlx::query_as(
"SELECT a.attname, ix.indisunique \
FROM pg_index ix \
JOIN pg_class t ON t.oid = ix.indrelid \
JOIN pg_namespace n ON n.oid = t.relnamespace \
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ix.indkey[0] \
WHERE n.nspname = 'public' AND t.relname = $1 \
AND ix.indnatts = 1 AND NOT ix.indisprimary",
)
.bind(table)
.fetch_all(pool)
.await
.unwrap_or_default();
let mut unique_cols = std::collections::HashSet::new();
let mut index_cols = std::collections::HashSet::new();
for (col, is_unique) in idx_rows {
if is_unique {
unique_cols.insert(col);
} else {
index_cols.insert(col);
}
}
let spatial = pg_spatial_columns(pool, table).await;
let enums = pg_enum_columns(pool, table).await;
let mut columns: Vec<IntrospectedColumn> = Vec::with_capacity(column_rows.len());
for (name, data_type, is_nullable, udt_name, raw_default) in column_rows {
let (choices, enum_type) = match enums.get(&name) {
Some((type_name, labels)) => (labels.clone(), Some(type_name.clone())),
None => (Vec::new(), None),
};
let ty = if !choices.is_empty() {
SqlType::Text
} else if udt_name.eq_ignore_ascii_case("geometry")
|| udt_name.eq_ignore_ascii_case("geography")
{
spatial.get(&name).copied().unwrap_or_else(|| {
let spec = crate::orm::GeometrySpec::DEFAULT;
if udt_name.eq_ignore_ascii_case("geography") {
SqlType::Geography(spec)
} else {
SqlType::Geometry(spec)
}
})
} else if data_type.eq_ignore_ascii_case("ARRAY") {
let elem_name = udt_name.strip_prefix('_').unwrap_or(udt_name.as_str());
map_postgres_array_element(elem_name).ok_or_else(|| {
InspectError::UnsupportedColumnType {
table: table.to_string(),
column: name.clone(),
sql_type: format!("ARRAY of {elem_name}"),
}
})?
} else {
map_postgres_type(&data_type).ok_or_else(|| InspectError::UnsupportedColumnType {
table: table.to_string(),
column: name.clone(),
sql_type: data_type.clone(),
})?
};
let fk_target = fk_map.get(&name).cloned();
let ty = if fk_target.is_some() {
SqlType::ForeignKey
} else {
ty
};
let primary_key = pk_columns.contains(&name);
let nullable = if primary_key {
false
} else {
is_nullable.eq_ignore_ascii_case("YES")
};
let unique = !primary_key && unique_cols.contains(&name);
let index = !primary_key && !unique && index_cols.contains(&name);
columns.push(IntrospectedColumn {
name,
ty,
primary_key,
nullable,
fk_target,
unique,
index,
default: raw_default,
auto_now_add: false,
auto_now: false,
choices,
enum_type,
});
}
Ok(columns)
}
async fn pg_composite_indexes(pool: &PgPool, table: &str) -> (Vec<Vec<String>>, Vec<Vec<String>>) {
let rows: Vec<(bool, Vec<String>)> = sqlx::query_as(
"SELECT ix.indisunique, array_agg(a.attname ORDER BY k.ord) AS cols \
FROM pg_index ix \
JOIN pg_class t ON t.oid = ix.indrelid \
JOIN pg_namespace n ON n.oid = t.relnamespace \
JOIN unnest(string_to_array(ix.indkey::text, ' ')::smallint[]) \
WITH ORDINALITY AS k(attnum, ord) ON true \
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum AND a.attnum > 0 \
WHERE n.nspname = 'public' AND t.relname = $1 \
AND ix.indnatts > 1 AND NOT ix.indisprimary \
GROUP BY ix.indexrelid, ix.indisunique",
)
.bind(table)
.fetch_all(pool)
.await
.unwrap_or_default();
let mut uniques = Vec::new();
let mut plains = Vec::new();
for (is_unique, cols) in rows {
if cols.len() < 2 {
continue; }
if is_unique {
uniques.push(cols);
} else {
plains.push(cols);
}
}
(uniques, plains)
}
async fn pg_spatial_columns(
pool: &PgPool,
table: &str,
) -> std::collections::HashMap<String, SqlType> {
use crate::orm::{GeometryKind, GeometrySpec};
let mut map = std::collections::HashMap::new();
let geom: Vec<(String, String, i32)> = sqlx::query_as(
"SELECT f_geometry_column, type, srid FROM geometry_columns \
WHERE f_table_schema = 'public' AND f_table_name = $1",
)
.bind(table)
.fetch_all(pool)
.await
.unwrap_or_default();
for (col, kind, srid) in geom {
let spec = GeometrySpec {
kind: GeometryKind::from_attr(&kind).unwrap_or(GeometryKind::Geometry),
srid,
};
map.insert(col, SqlType::Geometry(spec));
}
let geog: Vec<(String, String, i32)> = sqlx::query_as(
"SELECT f_geography_column, type, srid FROM geography_columns \
WHERE f_table_schema = 'public' AND f_table_name = $1",
)
.bind(table)
.fetch_all(pool)
.await
.unwrap_or_default();
for (col, kind, srid) in geog {
let spec = GeometrySpec {
kind: GeometryKind::from_attr(&kind).unwrap_or(GeometryKind::Geometry),
srid,
};
map.insert(col, SqlType::Geography(spec));
}
map
}
async fn pg_enum_columns(
pool: &PgPool,
table: &str,
) -> std::collections::HashMap<String, (String, Vec<String>)> {
let rows: Vec<(String, String, String)> = sqlx::query_as(
"SELECT a.attname, t.typname, e.enumlabel \
FROM pg_attribute a \
JOIN pg_type t ON t.oid = a.atttypid \
JOIN pg_enum e ON e.enumtypid = t.oid \
JOIN pg_class c ON c.oid = a.attrelid \
JOIN pg_namespace n ON n.oid = c.relnamespace \
WHERE n.nspname = 'public' AND c.relname = $1 \
AND a.attnum > 0 AND NOT a.attisdropped \
ORDER BY a.attnum, e.enumsortorder",
)
.bind(table)
.fetch_all(pool)
.await
.unwrap_or_default();
let mut map: std::collections::HashMap<String, (String, Vec<String>)> =
std::collections::HashMap::new();
for (col, type_name, label) in rows {
map.entry(col)
.or_insert_with(|| (type_name, Vec::new()))
.1
.push(label);
}
map
}
fn map_postgres_array_element(elem: &str) -> Option<SqlType> {
use crate::orm::ArrayElement;
let kind = match elem.trim().to_ascii_lowercase().as_str() {
"int2" => ArrayElement::SmallInt,
"int4" => ArrayElement::Integer,
"int8" => ArrayElement::BigInt,
"float4" => ArrayElement::Real,
"float8" => ArrayElement::Double,
"bool" => ArrayElement::Boolean,
"text" | "varchar" | "bpchar" => ArrayElement::Text,
"uuid" => ArrayElement::Uuid,
_ => return None,
};
Some(SqlType::Array(kind))
}
fn map_postgres_type(raw: &str) -> Option<SqlType> {
let normalised = raw.trim().to_ascii_lowercase();
match normalised.as_str() {
"smallint" => Some(SqlType::SmallInt),
"integer" => Some(SqlType::Integer),
"bigint" => Some(SqlType::BigInt),
"real" => Some(SqlType::Real),
"double precision" => Some(SqlType::Double),
"boolean" => Some(SqlType::Boolean),
"text" | "character varying" | "character" => Some(SqlType::Text),
"date" => Some(SqlType::Date),
"time without time zone" | "time with time zone" => Some(SqlType::Time),
"timestamp with time zone" => Some(SqlType::Timestamptz),
"timestamp without time zone" => Some(SqlType::Timestamp),
"uuid" => Some(SqlType::Uuid),
"json" | "jsonb" => Some(SqlType::Json),
"inet" => Some(SqlType::Inet),
"cidr" => Some(SqlType::Cidr),
"macaddr" => Some(SqlType::MacAddr),
"xml" => Some(SqlType::Xml),
"ltree" => Some(SqlType::Ltree),
"bit" | "bit varying" | "varbit" => Some(SqlType::Bit),
"tsvector" => Some(SqlType::FullText),
"numeric" | "decimal" => Some(SqlType::Decimal),
"bytea" => Some(SqlType::Bytes),
_ => None,
}
}
async fn introspect_columns(
pool: &SqlitePool,
table: &str,
) -> Result<Vec<IntrospectedColumn>, InspectError> {
let quoted = table.replace('"', "\"\"");
let sql = format!("PRAGMA table_info(\"{quoted}\")");
let mut rows = sqlx::query(&sql).fetch_all(pool).await?;
rows.sort_by_key(|r| r.try_get::<i64, _>("cid").unwrap_or(0));
let fk_map = sqlite_foreign_keys(pool, "ed).await?;
let (unique_cols, index_cols) = sqlite_indexed_columns(pool, "ed).await?;
let mut columns: Vec<IntrospectedColumn> = Vec::with_capacity(rows.len());
for row in rows {
let name: String = row.try_get("name")?;
let raw_type: String = row.try_get("type")?;
let notnull: i64 = row.try_get("notnull")?;
let pk: i64 = row.try_get("pk")?;
let raw_default: Option<String> = row.try_get("dflt_value").ok().flatten();
let fk_target = fk_map.get(&name).cloned();
let ty = if fk_target.is_some() {
SqlType::ForeignKey
} else {
map_sqlite_type(&raw_type).ok_or_else(|| InspectError::UnsupportedColumnType {
table: table.to_string(),
column: name.clone(),
sql_type: raw_type.clone(),
})?
};
let primary_key = pk != 0;
let nullable = if primary_key { false } else { notnull == 0 };
let unique = !primary_key && unique_cols.contains(&name);
let index = !primary_key && !unique && index_cols.contains(&name);
columns.push(IntrospectedColumn {
name,
ty,
primary_key,
nullable,
fk_target,
unique,
index,
default: raw_default,
auto_now_add: false,
auto_now: false,
choices: Vec::new(),
enum_type: None,
});
}
Ok(columns)
}
async fn sqlite_foreign_keys(
pool: &SqlitePool,
quoted_table: &str,
) -> Result<std::collections::HashMap<String, String>, InspectError> {
let rows = sqlx::query(&format!("PRAGMA foreign_key_list(\"{quoted_table}\")"))
.fetch_all(pool)
.await?;
let mut map = std::collections::HashMap::new();
for row in rows {
let from: String = row.try_get("from")?;
let target: String = row.try_get("table")?;
map.entry(from).or_insert(target);
}
Ok(map)
}
async fn sqlite_indexed_columns(
pool: &SqlitePool,
quoted_table: &str,
) -> Result<
(
std::collections::HashSet<String>,
std::collections::HashSet<String>,
),
InspectError,
> {
let mut unique = std::collections::HashSet::new();
let mut plain = std::collections::HashSet::new();
let index_rows = sqlx::query(&format!("PRAGMA index_list(\"{quoted_table}\")"))
.fetch_all(pool)
.await?;
for idx in index_rows {
let index_name: String = idx.try_get("name")?;
let is_unique: i64 = idx.try_get("unique")?;
let cols = sqlx::query(&format!(
"PRAGMA index_info(\"{}\")",
index_name.replace('"', "\"\"")
))
.fetch_all(pool)
.await?;
if cols.len() != 1 {
continue;
}
let col: String = cols[0].try_get("name")?;
if is_unique != 0 {
unique.insert(col);
} else {
plain.insert(col);
}
}
Ok((unique, plain))
}
async fn sqlite_composite_indexes(
pool: &SqlitePool,
table: &str,
) -> Result<(Vec<Vec<String>>, Vec<Vec<String>>), InspectError> {
let quoted = table.replace('"', "\"\"");
let mut uniques: Vec<Vec<String>> = Vec::new();
let mut plains: Vec<Vec<String>> = Vec::new();
let index_rows = sqlx::query(&format!("PRAGMA index_list(\"{quoted}\")"))
.fetch_all(pool)
.await?;
for idx in index_rows {
let index_name: String = idx.try_get("name")?;
let is_unique: i64 = idx.try_get("unique")?;
let origin: String = idx.try_get("origin").unwrap_or_default();
if origin == "pk" {
continue;
}
let cols_rows = sqlx::query(&format!(
"PRAGMA index_info(\"{}\")",
index_name.replace('"', "\"\"")
))
.fetch_all(pool)
.await?;
if cols_rows.len() < 2 {
continue; }
let mut cols: Vec<(i64, String)> = Vec::new();
for c in &cols_rows {
cols.push((c.try_get("seqno")?, c.try_get("name")?));
}
cols.sort_by_key(|(seq, _)| *seq);
let group: Vec<String> = cols.into_iter().map(|(_, n)| n).collect();
if is_unique != 0 {
uniques.push(group);
} else {
plains.push(group);
}
}
Ok((uniques, plains))
}
fn map_sqlite_type(raw: &str) -> Option<SqlType> {
let head = match raw.split_once('(') {
Some((before, _)) => before,
None => raw,
};
let normalised = head.trim().to_ascii_lowercase();
let base = normalised
.strip_suffix(" unsigned")
.or_else(|| normalised.strip_suffix(" signed"))
.map(str::trim_end)
.unwrap_or(normalised.as_str());
match base {
"smallint" | "int2" => Some(SqlType::SmallInt),
"int" | "integer" | "int4" => Some(SqlType::Integer),
"bigint" | "int8" => Some(SqlType::BigInt),
"real" | "float" | "float4" => Some(SqlType::Real),
"double" | "double precision" | "float8" => Some(SqlType::Double),
"boolean" | "bool" => Some(SqlType::Boolean),
"text" | "varchar" | "char" | "clob" | "character" | "varying character" | "nchar"
| "nvarchar" => Some(SqlType::Text),
"date" => Some(SqlType::Date),
"time" => Some(SqlType::Time),
"timestamp" | "timestamptz" | "datetime" => Some(SqlType::Timestamptz),
"uuid" => Some(SqlType::Uuid),
"json" | "jsonb" => Some(SqlType::Json),
"decimal" | "numeric" => Some(SqlType::Decimal),
"blob" | "bytea" => Some(SqlType::Bytes),
_ => None,
}
}
pub fn render_models(schema: &IntrospectedSchema) -> String {
render_models_with(schema, None, false)
}
const DJANGO_USER_TABLE: &str = "auth_user";
const DJANGO_USER_STRUCT: &str = "AuthUser";
pub(crate) fn resolve_struct_names(
schema: &IntrospectedSchema,
framework: Option<Framework>,
with_table_names: bool,
) -> std::collections::HashMap<String, String> {
let django = framework == Some(Framework::Django);
let app_labels: std::collections::HashSet<String> = schema
.tables
.iter()
.filter_map(|t| t.table.split_once('_').map(|(app, _)| app.to_string()))
.collect();
let mut struct_names: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
for t in &schema.tables {
let name = if django && t.table == DJANGO_USER_TABLE {
DJANGO_USER_STRUCT.to_string()
} else if django && with_table_names {
django_struct_name(&t.table, &app_labels)
} else {
t.name.clone()
};
*counts.entry(name.clone()).or_default() += 1;
struct_names.insert(t.table.clone(), name);
}
for t in &schema.tables {
let name = &struct_names[&t.table];
if counts[name] > 1 && !(django && t.table == DJANGO_USER_TABLE) {
struct_names.insert(t.table.clone(), pascal_case_from_table(&t.table));
}
}
struct_names
}
pub fn render_models_with(
schema: &IntrospectedSchema,
framework: Option<Framework>,
with_table_names: bool,
) -> String {
let django = framework == Some(Framework::Django);
let struct_names = resolve_struct_names(schema, framework, with_table_names);
let uses_auth_user = django
&& schema.tables.iter().any(|t| {
t.table == DJANGO_USER_TABLE
|| t.columns
.iter()
.any(|c| c.fk_target.as_deref() == Some(DJANGO_USER_TABLE))
});
let mut out = String::new();
out.push_str(HEADER);
if uses_auth_user {
out.push_str(AUTH_USER_IMPORT);
}
let mut enums: std::collections::BTreeMap<String, Vec<String>> =
std::collections::BTreeMap::new();
for table in &schema.tables {
for column in &table.columns {
if let Some(enum_type) = &column.enum_type {
if !column.choices.is_empty() {
enums
.entry(choices_enum_name(enum_type))
.or_insert_with(|| column.choices.clone());
}
}
}
}
for (name, labels) in &enums {
out.push('\n');
out.push_str(&render_choices_enum(name, labels));
}
let mut tables: Vec<&IntrospectedTable> = schema.tables.iter().collect();
tables.sort_by(|a, b| struct_names[&a.table].cmp(&struct_names[&b.table]));
for table in tables {
if django && table.table == DJANGO_USER_TABLE {
continue;
}
out.push('\n');
out.push_str(&render_one_struct(table, &struct_names));
}
out
}
fn choices_enum_name(enum_type: &str) -> String {
umbral_casing::pascal_case_from_ident(enum_type)
}
fn render_choices_enum(rust_name: &str, labels: &[String]) -> String {
let variants: Vec<String> = labels
.iter()
.enumerate()
.map(|(i, l)| {
let ident = pascal_case_from_table(l);
if ident.is_empty() {
format!("Variant{i}")
} else {
ident
}
})
.collect();
let screaming_round_trips = variants
.iter()
.zip(labels)
.all(|(v, l)| to_snake_case(v).to_ascii_uppercase() == *l);
let mut out = String::new();
out.push_str(
"#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, Choices)]\n",
);
if screaming_round_trips {
out.push_str("#[choices(rename_all = \"SCREAMING_SNAKE_CASE\")]\n");
out.push_str("#[serde(rename_all = \"SCREAMING_SNAKE_CASE\")]\n");
}
out.push_str(&format!("pub enum {rust_name} {{\n"));
for (variant, label) in variants.iter().zip(labels) {
if !screaming_round_trips {
let escaped = label.replace('\\', "\\\\").replace('"', "\\\"");
out.push_str(&format!(" #[choices(value = \"{escaped}\")]\n"));
out.push_str(&format!(" #[serde(rename = \"{escaped}\")]\n"));
}
out.push_str(&format!(" {variant},\n"));
}
out.push_str("}\n");
out
}
fn django_struct_name(table: &str, app_labels: &std::collections::HashSet<String>) -> String {
let model_part = table
.split_once('_')
.filter(|(app, rest)| app_labels.contains(*app) && !rest.is_empty())
.map(|(_, rest)| rest)
.unwrap_or(table);
pascal_case_from_table(model_part)
}
const AUTH_USER_IMPORT: &str = "\
// This schema references Django's `auth_user`, mapped to umbral-auth's built-in
// `AuthUser` (same `auth_user` table). If you use a CUSTOM user model, replace
// the line below with your own, e.g. `use crate::models::MyUser as AuthUser;`.
use umbral_auth::AuthUser;
";
const HEADER: &str = "\
//! Generated by `umbral inspectdb`. Wire each struct into your App
//! builder with `.model::<StructName>()`. Re-run `inspectdb` to
//! regenerate; edits made by hand will be lost.
use umbral::prelude::*;
";
fn is_temporal(ty: SqlType) -> bool {
matches!(
ty,
SqlType::Timestamptz | SqlType::Timestamp | SqlType::Date | SqlType::Time
)
}
fn is_current_timestamp_default(raw: &str) -> bool {
let s = raw.trim();
let s = s.split("::").next().unwrap_or(s).trim();
let s = s.trim_end_matches("()").trim();
s.eq_ignore_ascii_case("CURRENT_TIMESTAMP")
|| s.eq_ignore_ascii_case("now")
|| s.eq_ignore_ascii_case("LOCALTIMESTAMP")
}
fn clean_constant_default(raw: &str) -> Option<String> {
let s = raw.trim();
if s.is_empty() || s.eq_ignore_ascii_case("null") {
return None;
}
let s = s.split("::").next().unwrap_or(s).trim();
if s.len() >= 2 && s.starts_with('\'') && s.ends_with('\'') {
return Some(s[1..s.len() - 1].replace("''", "'"));
}
if !s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
{
return Some(s.to_string());
}
None
}
pub fn apply_recovered_conventions(schema: &mut IntrospectedSchema, framework: Option<Framework>) {
let has_framework = framework.is_some();
for table in &mut schema.tables {
for col in &mut table.columns {
if let Some(raw) = col.default.take() {
if is_temporal(col.ty) && is_current_timestamp_default(&raw) {
col.auto_now_add = true;
} else {
col.default = clean_constant_default(&raw);
}
}
if has_framework && is_temporal(col.ty) && col.default.is_none() && !col.auto_now_add {
let lower = col.name.to_ascii_lowercase();
if lower.starts_with("created")
|| lower.starts_with("added")
|| lower == "date_joined"
{
col.auto_now_add = true;
} else if !col.auto_now
&& (lower.starts_with("updated")
|| lower.starts_with("modified")
|| lower.starts_with("changed"))
{
col.auto_now = true;
}
}
}
}
}
fn framework_field_name(col: &IntrospectedColumn, framework: Framework) -> Option<String> {
let is_fk = col.fk_target.is_some();
match framework {
Framework::Django | Framework::Rails | Framework::Laravel => {
if is_fk {
col.name
.strip_suffix("_id")
.filter(|b| !b.is_empty())
.map(str::to_string)
} else {
None
}
}
Framework::Prisma => {
let base = if is_fk {
col.name.strip_suffix("Id").unwrap_or(&col.name)
} else {
&col.name
};
let snake = to_snake_case(base);
(snake != col.name && !snake.is_empty()).then_some(snake)
}
}
}
fn apply_framework_column_names(schema: &mut IntrospectedSchema, framework: Framework) {
for table in &mut schema.tables {
let existing: std::collections::HashSet<&str> =
table.columns.iter().map(|c| c.name.as_str()).collect();
let mut renames: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
let mut claimed: std::collections::HashSet<String> = std::collections::HashSet::new();
for col in &table.columns {
if let Some(new) = framework_field_name(col, framework) {
if !existing.contains(new.as_str()) && claimed.insert(new.clone()) {
renames.insert(col.name.clone(), new);
}
}
}
if renames.is_empty() {
continue;
}
for col in &mut table.columns {
if let Some(new) = renames.get(&col.name) {
col.name = new.clone();
}
}
for group in table
.unique_together
.iter_mut()
.chain(table.indexes.iter_mut())
{
for c in group.iter_mut() {
if let Some(new) = renames.get(c) {
*c = new.clone();
}
}
}
}
}
fn pick_m2m_owner(join_table: &str, ta: &str, tb: &str) -> Option<(String, String, String)> {
let try_owner = |owner: &str, target: &str| -> Option<(String, String, String)> {
join_table
.strip_prefix(&format!("{owner}_"))
.filter(|field| !field.is_empty())
.map(|field| (owner.to_string(), field.to_string(), target.to_string()))
};
match (try_owner(ta, tb), try_owner(tb, ta)) {
(Some(ra), Some(rb)) => Some(if ta.len() >= tb.len() { ra } else { rb }),
(Some(r), None) | (None, Some(r)) => Some(r),
(None, None) => None,
}
}
fn pick_m2m(
framework: Framework,
table: &str,
fk_cols: &[&IntrospectedColumn],
) -> Option<(String, String, String)> {
let ta = fk_cols[0].fk_target.as_deref().unwrap();
let tb = fk_cols[1].fk_target.as_deref().unwrap();
match framework {
Framework::Prisma => {
if !(table.starts_with('_') && table.contains("To")) {
return None;
}
let by_name = |name: &str| {
fk_cols
.iter()
.find(|c| c.name == name)
.and_then(|c| c.fk_target.as_deref())
};
let owner = by_name("A").unwrap_or(ta);
let child = by_name("B").unwrap_or(tb);
Some((owner.to_string(), to_snake_case(child), child.to_string()))
}
_ => pick_m2m_owner(table, ta, tb),
}
}
fn detect_m2m_relations(
schema: &mut IntrospectedSchema,
framework: Option<Framework>,
with_table_names: bool,
) {
let Some(fw) = framework else {
return;
};
let struct_names = resolve_struct_names(schema, framework, with_table_names);
let owner_cols: std::collections::HashMap<String, std::collections::HashSet<String>> = schema
.tables
.iter()
.map(|t| {
(
t.table.clone(),
t.columns.iter().map(|c| c.name.clone()).collect(),
)
})
.collect();
let mut to_remove: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut additions: Vec<(String, IntrospectedM2M)> = Vec::new();
for t in &schema.tables {
let fk_cols: Vec<&IntrospectedColumn> =
t.columns.iter().filter(|c| c.fk_target.is_some()).collect();
let is_junction = fk_cols.len() == 2
&& t.columns
.iter()
.all(|c| c.fk_target.is_some() || c.primary_key);
if !is_junction {
continue;
}
let Some((owner_table, field, target_table)) = pick_m2m(fw, &t.table, &fk_cols) else {
continue;
};
if owner_table == DJANGO_USER_TABLE {
continue;
}
if owner_cols
.get(&owner_table)
.is_some_and(|cols| cols.contains(&field))
{
continue;
}
let target_name = struct_names
.get(&target_table)
.cloned()
.unwrap_or_else(|| pascal_case_from_table(&target_table));
additions.push((
owner_table,
IntrospectedM2M {
field_name: field,
target_table,
target_name,
},
));
to_remove.insert(t.table.clone());
}
for (owner_table, m2m) in additions {
if let Some(owner) = schema.tables.iter_mut().find(|t| t.table == owner_table) {
owner.m2m.push(m2m);
}
}
schema.tables.retain(|t| !to_remove.contains(&t.table));
}
fn render_one_struct(
table: &IntrospectedTable,
struct_names: &std::collections::HashMap<String, String>,
) -> String {
let this_struct = struct_names
.get(&table.table)
.cloned()
.unwrap_or_else(|| table.name.clone());
let resolve_target = |target: &str| -> String {
struct_names
.get(target)
.cloned()
.unwrap_or_else(|| pascal_case_from_table(target))
};
let mut out = String::new();
out.push_str(
"#[derive(Debug, Clone, sqlx::FromRow, serde::Serialize, serde::Deserialize, Model)]\n",
);
if to_snake_case(&this_struct) != table.table {
out.push_str(&format!("#[umbral(table = \"{}\")]\n", table.table));
}
if let Some(attr) = composite_groups_attr("unique_together", &table.unique_together) {
out.push_str(&attr);
}
if let Some(attr) = composite_groups_attr("indexes", &table.indexes) {
out.push_str(&attr);
}
out.push_str(&format!("pub struct {this_struct} {{\n"));
for column in &table.columns {
if column.unique {
out.push_str(" #[umbral(unique)]\n");
}
if column.index {
out.push_str(" #[umbral(index)]\n");
}
if column.auto_now_add {
out.push_str(" #[umbral(auto_now_add)]\n");
} else if column.auto_now {
out.push_str(" #[umbral(auto_now)]\n");
} else if let Some(def) = &column.default {
out.push_str(&format!(
" #[umbral(default = \"{}\")]\n",
def.replace('\\', "\\\\").replace('"', "\\\"")
));
}
if column.primary_key && column.name != "id" {
out.push_str(" #[umbral(primary_key)]\n");
}
if let Some(attr) = geometry_attr(column.ty) {
out.push_str(&format!(" {attr}\n"));
}
let is_enum_column = column.enum_type.is_some() && !column.choices.is_empty();
if is_enum_column {
out.push_str(" #[umbral(choices)]\n");
}
let (desired, ty) = match &column.fk_target {
Some(target) => {
let target_struct = resolve_target(target);
let ty = if column.nullable {
format!("Option<ForeignKey<{target_struct}>>")
} else {
format!("ForeignKey<{target_struct}>")
};
(column.name.clone(), ty)
}
None => match &column.enum_type {
Some(enum_type) if !column.choices.is_empty() => {
let enum_name = choices_enum_name(enum_type);
let ty = if column.nullable {
format!("Option<{enum_name}>")
} else {
enum_name
};
(column.name.clone(), ty)
}
_ => (
column.name.clone(),
render_field_type(column.ty, column.nullable),
),
},
};
let field_name = safe_field_ident(&desired);
if field_name != column.name {
out.push_str(&format!(" #[sqlx(rename = \"{}\")]\n", column.name));
}
out.push_str(&format!(" pub {field_name}: {ty},\n"));
}
let parent_pk_ty = table
.columns
.iter()
.find(|c| c.primary_key)
.map(|c| render_field_type(c.ty, false));
for m2m in &table.m2m {
let field = safe_field_ident(&m2m.field_name);
let target = resolve_target(&m2m.target_table);
let ty = match parent_pk_ty.as_deref() {
Some(pk) if pk != "i64" => format!("M2M<{target}, {pk}>"),
_ => format!("M2M<{target}>"),
};
out.push_str(&format!(" pub {field}: {ty},\n"));
}
out.push_str("}\n");
out
}
fn is_rust_keyword(s: &str) -> bool {
matches!(
s,
"as" | "break"
| "const"
| "continue"
| "crate"
| "dyn"
| "else"
| "enum"
| "extern"
| "false"
| "fn"
| "for"
| "if"
| "impl"
| "in"
| "let"
| "loop"
| "match"
| "mod"
| "move"
| "mut"
| "pub"
| "ref"
| "return"
| "self"
| "Self"
| "static"
| "struct"
| "super"
| "trait"
| "true"
| "type"
| "unsafe"
| "use"
| "where"
| "while"
| "async"
| "await"
| "box"
| "final"
| "macro"
| "override"
| "priv"
| "typeof"
| "unsized"
| "virtual"
| "yield"
)
}
fn composite_groups_attr(name: &str, groups: &[Vec<String>]) -> Option<String> {
if groups.is_empty() {
return None;
}
let rendered = groups
.iter()
.map(|g| {
let cols = g
.iter()
.map(|c| format!("\"{c}\""))
.collect::<Vec<_>>()
.join(", ");
format!("[{cols}]")
})
.collect::<Vec<_>>()
.join(", ");
Some(format!("#[umbral({name} = [{rendered}])]\n"))
}
fn geometry_attr(ty: SqlType) -> Option<String> {
use crate::orm::GeometryKind;
let (base, spec) = match ty {
SqlType::Geometry(s) => ("geometry", s),
SqlType::Geography(s) => ("geography", s),
_ => return None,
};
let kind = match spec.kind {
GeometryKind::Geometry => "geometry",
GeometryKind::Point => "point",
GeometryKind::LineString => "linestring",
GeometryKind::Polygon => "polygon",
GeometryKind::MultiPoint => "multipoint",
GeometryKind::MultiLineString => "multilinestring",
GeometryKind::MultiPolygon => "multipolygon",
GeometryKind::GeometryCollection => "geometrycollection",
};
Some(format!(
"#[umbral({base} = \"{kind}\", srid = {})]",
spec.srid
))
}
fn safe_field_ident(name: &str) -> String {
if is_rust_keyword(name) {
format!("{name}_")
} else {
name.to_string()
}
}
fn render_field_type(ty: SqlType, nullable: bool) -> String {
let base = match ty {
SqlType::SmallInt => "i16".to_string(),
SqlType::Integer => "i32".to_string(),
SqlType::BigInt => "i64".to_string(),
SqlType::Real => "f32".to_string(),
SqlType::Double => "f64".to_string(),
SqlType::Boolean => "bool".to_string(),
SqlType::Text => "String".to_string(),
SqlType::Date => "chrono::NaiveDate".to_string(),
SqlType::Time => "chrono::NaiveTime".to_string(),
SqlType::Timestamptz => "chrono::DateTime<chrono::Utc>".to_string(),
SqlType::Timestamp => "chrono::NaiveDateTime".to_string(),
SqlType::Uuid => "uuid::Uuid".to_string(),
SqlType::Json => "serde_json::Value".to_string(),
SqlType::Array(elem) => format!("Vec<{}>", render_field_type(elem.to_sql_type(), false)),
SqlType::Inet => "ipnetwork::IpNetwork".to_string(),
SqlType::Cidr => "ipnetwork::IpNetwork".to_string(),
SqlType::MacAddr => "mac_address::MacAddress".to_string(),
SqlType::Xml => "String".to_string(),
SqlType::Ltree => "String".to_string(),
SqlType::Bit => "String".to_string(),
SqlType::FullText => "umbral::orm::TsVector".to_string(),
SqlType::ForeignKey => "i64".to_string(),
SqlType::Bytes => "Vec<u8>".to_string(),
SqlType::Decimal => "rust_decimal::Decimal".to_string(),
SqlType::DecimalN(_) => "rust_decimal::Decimal".to_string(),
SqlType::BigDecimal => "bigdecimal::BigDecimal".to_string(),
SqlType::Geometry(_) | SqlType::Geography(_) => "umbral::orm::gis::Geometry".to_string(),
};
let base = base.as_str();
if nullable {
format!("Option<{base}>")
} else {
base.to_string()
}
}
fn fk_topo_order_tables(tables: &[IntrospectedTable]) -> Vec<&IntrospectedTable> {
use std::collections::{BTreeMap, HashSet};
let in_schema: HashSet<&str> = tables.iter().map(|t| t.table.as_str()).collect();
let mut deps: BTreeMap<&str, HashSet<&str>> = BTreeMap::new();
for t in tables {
let mut targets = HashSet::new();
for col in &t.columns {
if let Some(target) = col.fk_target.as_deref() {
if target != t.table.as_str() && in_schema.contains(target) {
targets.insert(target);
}
}
}
deps.insert(t.table.as_str(), targets);
}
let by_name = |name: &str| tables.iter().find(|t| t.table.as_str() == name);
let mut ordered: Vec<&IntrospectedTable> = Vec::with_capacity(tables.len());
while !deps.is_empty() {
let ready: Vec<&str> = deps
.iter()
.filter(|(_, d)| d.is_empty())
.map(|(t, _)| *t)
.collect();
if ready.is_empty() {
for t in tables {
if deps.contains_key(t.table.as_str()) {
ordered.push(t);
}
}
break;
}
for t in &ready {
if let Some(table) = by_name(t) {
ordered.push(table);
}
deps.remove(t);
}
for set in deps.values_mut() {
for t in &ready {
set.remove(t);
}
}
}
ordered
}
pub fn render_initial_migration(schema: &IntrospectedSchema) -> MigrationFile {
let mut models: Vec<ModelMeta> = schema
.tables
.iter()
.map(|t| ModelMeta {
name: t.name.clone(),
table: t.table.clone(),
fields: t.columns.iter().map(Column::from).collect(),
display: t.name.clone(),
icon: "database".to_string(),
database: None,
singleton: false,
unique_together: Vec::new(),
indexes: Vec::new(),
ordering: Vec::new(),
m2m_relations: t
.m2m
.iter()
.map(|r| crate::migrate::M2MRelation {
field_name: r.field_name.clone(),
target_table: r.target_table.clone(),
target_name: r.target_name.clone(),
})
.collect(),
soft_delete: false,
audited: false,
view: None,
materialized: false,
app_label: "app".to_string(),
})
.collect();
models.sort_by(|a, b| a.name.cmp(&b.name));
let mut operations: Vec<Operation> = fk_topo_order_tables(&schema.tables)
.into_iter()
.map(|t| Operation::CreateTable {
table: t.table.clone(),
columns: t.columns.iter().map(Column::from).collect(),
unique_together: t.unique_together.clone(),
indexes: t.indexes.clone(),
})
.collect();
let pk_of = |table_name: &str| -> (String, SqlType) {
schema
.tables
.iter()
.find(|t| t.table == table_name)
.and_then(|t| t.columns.iter().find(|c| c.primary_key))
.map(|c| (c.name.clone(), c.ty))
.unwrap_or_else(|| ("id".to_string(), SqlType::BigInt))
};
for t in &schema.tables {
for m2m in &t.m2m {
let (parent_col, parent_ty) = pk_of(&t.table);
let (child_col, child_ty) = pk_of(&m2m.target_table);
operations.push(Operation::CreateM2MTable {
junction_table: format!("{}_{}", t.table, m2m.field_name),
parent_table: t.table.clone(),
parent_col,
child_table: m2m.target_table.clone(),
child_col,
parent_ty,
child_ty,
});
}
}
MigrationFile {
id: INITIAL_MIGRATION_ID.to_string(),
plugin: INSPECTED_PLUGIN_NAME.to_string(),
depends_on: Vec::new(),
operations,
snapshot_after: Snapshot { models },
replaces: Vec::new(),
}
}
pub async fn write_outputs(
output: &Path,
models_src: &str,
migration: &MigrationFile,
) -> Result<InspectReport, InspectError> {
std::fs::create_dir_all(output)?;
let models_path = output.join("models.rs");
std::fs::write(&models_path, models_src)?;
let plugin_dir = output.join("migrations").join(INSPECTED_PLUGIN_NAME);
std::fs::create_dir_all(&plugin_dir)?;
let migration_path = plugin_dir.join(format!("{}.json", migration.id));
let json = serde_json::to_string_pretty(migration)?;
std::fs::write(&migration_path, json)?;
let (tables, columns) =
migration
.operations
.iter()
.fold((0usize, 0usize), |(t, c), op| match op {
Operation::CreateTable { columns, .. } => (t + 1, c + columns.len()),
Operation::CreateM2MTable { .. } => (t + 1, c + 2),
Operation::CreateView { .. }
| Operation::DropView { .. }
| Operation::DropTable { .. }
| Operation::DropM2MTable { .. }
| Operation::AddColumn { .. }
| Operation::DropColumn { .. }
| Operation::AlterColumn { .. }
| Operation::RenameTable { .. }
| Operation::RenameColumn { .. }
| Operation::SetColumnComment { .. }
| Operation::AddIndex { .. }
| Operation::DropIndex { .. }
| Operation::RunSql { .. } => (t, c),
});
Ok(InspectReport {
tables,
columns,
models_path,
migration_path,
})
}
impl From<&IntrospectedColumn> for Column {
fn from(c: &IntrospectedColumn) -> Self {
Self {
name: c.name.clone(),
ty: c.ty,
primary_key: c.primary_key,
nullable: c.nullable,
fk_target: c.fk_target.clone(),
noform: false,
privileged: false,
private: false,
secret: false,
db_constraint: true,
noedit: false,
is_string_repr: false,
max_length: 0,
choices: c.choices.clone(),
choice_labels: c.choices.clone(),
default: c.default.clone().unwrap_or_default(),
is_multichoice: false,
unique: c.unique,
on_delete: crate::orm::FkAction::NoAction,
on_update: crate::orm::FkAction::NoAction,
index: c.index,
auto_now_add: c.auto_now_add,
auto_uuid: false,
auto_now: c.auto_now,
auto_user_add: false,
auto_user: false,
trim: false,
lowercase: false,
case_insensitive: false,
help: String::new(),
example: String::new(),
widget: None,
supported_backends: Vec::new(),
min: None,
max: None,
text_format: ::core::option::Option::None,
slug_from: ::core::option::Option::None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn col(name: &str, ty: SqlType, primary_key: bool, nullable: bool) -> IntrospectedColumn {
IntrospectedColumn {
name: name.to_string(),
ty,
primary_key,
nullable,
fk_target: None,
unique: false,
index: false,
default: None,
auto_now_add: false,
auto_now: false,
choices: Vec::new(),
enum_type: None,
}
}
#[test]
fn empty_schema_renders_header_only() {
let out = render_models(&IntrospectedSchema { tables: Vec::new() });
assert_eq!(out, HEADER);
}
#[test]
fn snake_case_table_skips_attribute_when_derive_round_trips() {
let schema = IntrospectedSchema {
tables: vec![IntrospectedTable {
table: "blog_post".to_string(),
name: "BlogPost".to_string(),
columns: vec![
col("id", SqlType::BigInt, true, false),
col("title", SqlType::Text, false, false),
],
unique_together: Vec::new(),
indexes: Vec::new(),
m2m: Vec::new(),
}],
};
let out = render_models(&schema);
assert!(!out.contains("#[umbral(table"));
assert!(out.contains("pub struct BlogPost {"));
assert!(out.contains("pub id: i64,"));
assert!(out.contains("pub title: String,"));
}
#[test]
fn lowercase_single_word_table_skips_attribute() {
let schema = IntrospectedSchema {
tables: vec![IntrospectedTable {
table: "post".to_string(),
name: "Post".to_string(),
columns: vec![col("id", SqlType::BigInt, true, false)],
unique_together: Vec::new(),
indexes: Vec::new(),
m2m: Vec::new(),
}],
};
let out = render_models(&schema);
assert!(!out.contains("#[umbral(table"));
assert!(out.contains("pub struct Post {"));
}
#[test]
fn non_round_tripping_table_name_keeps_attribute() {
let schema = IntrospectedSchema {
tables: vec![IntrospectedTable {
table: "POSTS".to_string(),
name: "Posts".to_string(),
columns: vec![col("id", SqlType::BigInt, true, false)],
unique_together: Vec::new(),
indexes: Vec::new(),
m2m: Vec::new(),
}],
};
let out = render_models(&schema);
assert!(out.contains("#[umbral(table = \"POSTS\")]"));
}
#[test]
fn nullable_column_wraps_in_option() {
let schema = IntrospectedSchema {
tables: vec![IntrospectedTable {
table: "post".to_string(),
name: "Post".to_string(),
columns: vec![
col("id", SqlType::BigInt, true, false),
col("published_at", SqlType::Timestamptz, false, true),
],
unique_together: Vec::new(),
indexes: Vec::new(),
m2m: Vec::new(),
}],
};
let out = render_models(&schema);
assert!(out.contains("pub published_at: Option<chrono::DateTime<chrono::Utc>>,"));
}
#[test]
fn type_catalogue_renders_each_sql_type() {
let schema = IntrospectedSchema {
tables: vec![IntrospectedTable {
table: "kitchen_sink".to_string(),
name: "KitchenSink".to_string(),
columns: vec![
col("id", SqlType::BigInt, true, false),
col("small", SqlType::SmallInt, false, false),
col("medium", SqlType::Integer, false, false),
col("real_v", SqlType::Real, false, false),
col("double_v", SqlType::Double, false, false),
col("flag", SqlType::Boolean, false, false),
col("note", SqlType::Text, false, false),
col("day", SqlType::Date, false, false),
col("clock", SqlType::Time, false, false),
col("at", SqlType::Timestamptz, false, false),
col("uid", SqlType::Uuid, false, false),
],
unique_together: Vec::new(),
indexes: Vec::new(),
m2m: Vec::new(),
}],
};
let out = render_models(&schema);
for expected in [
"pub id: i64,",
"pub small: i16,",
"pub medium: i32,",
"pub real_v: f32,",
"pub double_v: f64,",
"pub flag: bool,",
"pub note: String,",
"pub day: chrono::NaiveDate,",
"pub clock: chrono::NaiveTime,",
"pub at: chrono::DateTime<chrono::Utc>,",
"pub uid: uuid::Uuid,",
] {
assert!(out.contains(expected), "missing field render: {expected}");
}
}
#[test]
fn structs_are_sorted_by_name() {
let schema = IntrospectedSchema {
tables: vec![
IntrospectedTable {
table: "zebra".to_string(),
name: "Zebra".to_string(),
columns: vec![col("id", SqlType::BigInt, true, false)],
unique_together: Vec::new(),
indexes: Vec::new(),
m2m: Vec::new(),
},
IntrospectedTable {
table: "antelope".to_string(),
name: "Antelope".to_string(),
columns: vec![col("id", SqlType::BigInt, true, false)],
unique_together: Vec::new(),
indexes: Vec::new(),
m2m: Vec::new(),
},
],
};
let out = render_models(&schema);
let antelope_at = out.find("struct Antelope").expect("Antelope rendered");
let zebra_at = out.find("struct Zebra").expect("Zebra rendered");
assert!(antelope_at < zebra_at);
}
#[test]
fn header_carries_the_regen_warning_and_facade_import() {
let out = render_models(&IntrospectedSchema { tables: Vec::new() });
assert!(out.contains("Generated by `umbral inspectdb`"));
assert!(out.contains("edits made by hand will be lost"));
assert!(out.contains("use umbral::prelude::*;"));
}
#[test]
fn map_sqlite_type_strips_signedness_qualifier() {
assert_eq!(
map_sqlite_type("smallint unsigned"),
Some(SqlType::SmallInt)
);
assert_eq!(map_sqlite_type("integer unsigned"), Some(SqlType::Integer));
assert_eq!(map_sqlite_type("bigint unsigned"), Some(SqlType::BigInt));
assert_eq!(map_sqlite_type("INTEGER UNSIGNED"), Some(SqlType::Integer));
assert_eq!(map_sqlite_type("int signed"), Some(SqlType::Integer));
assert_eq!(map_sqlite_type("integer"), Some(SqlType::Integer));
}
#[test]
fn map_sqlite_type_maps_decimal_and_numeric() {
assert_eq!(map_sqlite_type("decimal"), Some(SqlType::Decimal));
assert_eq!(map_sqlite_type("numeric"), Some(SqlType::Decimal));
assert_eq!(map_sqlite_type("DECIMAL(9,6)"), Some(SqlType::Decimal));
assert_eq!(map_sqlite_type("numeric(10, 2)"), Some(SqlType::Decimal));
}
#[test]
fn map_postgres_type_covers_the_full_catalogue() {
assert_eq!(map_postgres_type("smallint"), Some(SqlType::SmallInt));
assert_eq!(map_postgres_type("integer"), Some(SqlType::Integer));
assert_eq!(map_postgres_type("bigint"), Some(SqlType::BigInt));
assert_eq!(map_postgres_type("real"), Some(SqlType::Real));
assert_eq!(map_postgres_type("double precision"), Some(SqlType::Double));
assert_eq!(map_postgres_type("boolean"), Some(SqlType::Boolean));
assert_eq!(map_postgres_type("text"), Some(SqlType::Text));
assert_eq!(
map_postgres_type("character varying"),
Some(SqlType::Text),
"VARCHAR maps to Text",
);
assert_eq!(
map_postgres_type("character"),
Some(SqlType::Text),
"CHAR maps to Text",
);
assert_eq!(map_postgres_type("date"), Some(SqlType::Date));
assert_eq!(
map_postgres_type("time without time zone"),
Some(SqlType::Time),
);
assert_eq!(
map_postgres_type("time with time zone"),
Some(SqlType::Time)
);
assert_eq!(
map_postgres_type("timestamp without time zone"),
Some(SqlType::Timestamp),
);
assert_eq!(
map_postgres_type("timestamp with time zone"),
Some(SqlType::Timestamptz),
);
assert_eq!(map_postgres_type("uuid"), Some(SqlType::Uuid));
assert_eq!(map_postgres_type("json"), Some(SqlType::Json));
assert_eq!(map_postgres_type("jsonb"), Some(SqlType::Json));
assert_eq!(map_postgres_type("inet"), Some(SqlType::Inet));
assert_eq!(map_postgres_type("cidr"), Some(SqlType::Cidr));
assert_eq!(map_postgres_type("macaddr"), Some(SqlType::MacAddr));
assert_eq!(map_postgres_type("bytea"), Some(SqlType::Bytes));
assert_eq!(map_postgres_type("numeric"), Some(SqlType::Decimal));
assert_eq!(map_postgres_type("decimal"), Some(SqlType::Decimal));
}
#[test]
fn map_postgres_type_returns_none_for_postgres_only_types() {
assert_eq!(map_postgres_type("ARRAY"), None);
}
#[test]
fn map_postgres_type_is_case_insensitive_on_input() {
assert_eq!(map_postgres_type("INTEGER"), Some(SqlType::Integer));
assert_eq!(map_postgres_type("Bigint"), Some(SqlType::BigInt));
assert_eq!(map_postgres_type("UUID"), Some(SqlType::Uuid));
}
#[test]
fn map_postgres_type_trims_whitespace() {
assert_eq!(map_postgres_type(" bigint "), Some(SqlType::BigInt));
}
}