use super::collection::SQLiteDDL;
use super::ddl::{CheckConstraint, Column, ForeignKey, Index, Table, UniqueConstraint, View};
use crate::utils::escape_for_rust_literal;
use drizzle_types::sqlite::SQLTypeCategory;
use heck::{ToLowerCamelCase, ToPascalCase, ToSnakeCase};
use std::collections::{HashMap, HashSet};
use std::fmt::Write;
#[derive(Debug, Clone, Default)]
pub struct GeneratedSchema {
pub code: String,
pub tables: Vec<String>,
pub indexes: Vec<String>,
pub views: 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 field_casing: FieldCasing,
}
#[derive(Debug, Clone, Copy, Default)]
pub enum FieldCasing {
#[default]
Snake,
Camel,
Preserve,
}
fn sanitize_rust_identifier(name: &str) -> String {
let mut out = String::with_capacity(name.len());
for (idx, ch) in name.chars().enumerate() {
let valid = if idx == 0 {
ch == '_' || ch.is_ascii_alphabetic()
} else {
ch == '_' || ch.is_ascii_alphanumeric()
};
if valid {
out.push(ch);
} else {
out.push('_');
}
}
if out.is_empty() { "_".to_string() } else { out }
}
fn apply_field_casing(name: &str, casing: FieldCasing) -> String {
match casing {
FieldCasing::Snake => name.to_snake_case(),
FieldCasing::Camel => name.to_lower_camel_case(),
FieldCasing::Preserve => sanitize_rust_identifier(name),
}
}
struct SchemaMaps<'a> {
table_columns: HashMap<String, Vec<&'a Column>>,
table_pks: HashMap<String, HashSet<String>>,
single_unique_columns: HashMap<String, HashSet<String>>,
table_uniques: HashMap<String, Vec<&'a UniqueConstraint>>,
table_checks: HashMap<String, Vec<&'a CheckConstraint>>,
fk_map: HashMap<(String, String), (&'a ForeignKey, usize)>,
}
fn build_schema_maps(ddl: &SQLiteDDL) -> SchemaMaps<'_> {
let mut table_columns: HashMap<String, Vec<&Column>> = HashMap::new();
for column in ddl.columns.list() {
table_columns
.entry(column.table.to_string())
.or_default()
.push(column);
}
let mut table_pks: HashMap<String, HashSet<String>> = HashMap::new();
for pk in ddl.pks.list() {
for col in pk.columns.iter() {
table_pks
.entry(pk.table.to_string())
.or_default()
.insert(col.to_string());
}
}
let mut single_unique_columns: HashMap<String, HashSet<String>> = HashMap::new();
let mut table_uniques: HashMap<String, Vec<&UniqueConstraint>> = HashMap::new();
for unique in ddl.uniques.list() {
table_uniques
.entry(unique.table.to_string())
.or_default()
.push(unique);
if unique.columns.len() == 1 && !unique.name_explicit {
single_unique_columns
.entry(unique.table.to_string())
.or_default()
.insert(unique.columns[0].to_string());
}
}
let mut table_checks: HashMap<String, Vec<&CheckConstraint>> = HashMap::new();
for check in ddl.checks.list() {
table_checks
.entry(check.table.to_string())
.or_default()
.push(check);
}
let mut fk_map: HashMap<(String, String), (&ForeignKey, usize)> = HashMap::new();
for fk in ddl.fks.list() {
for (idx, col) in fk.columns.iter().enumerate() {
fk_map.insert((fk.table.to_string(), col.to_string()), (fk, idx));
}
}
SchemaMaps {
table_columns,
table_pks,
single_unique_columns,
table_uniques,
table_checks,
fk_map,
}
}
fn write_module_header(code: &mut String, options: &CodegenOptions) {
code.push_str("//! Auto-generated SQLite 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::sqlite::prelude::*;\n\n");
}
#[must_use]
pub fn generate_rust_schema(ddl: &SQLiteDDL, options: &CodegenOptions) -> GeneratedSchema {
let mut result = GeneratedSchema::default();
let mut code = String::new();
write_module_header(&mut code, options);
let SchemaMaps {
table_columns,
table_pks,
single_unique_columns,
table_uniques,
table_checks,
fk_map,
} = build_schema_maps(ddl);
for table in ddl.tables.list() {
let table_name = table.name.to_string();
let columns = table_columns
.get(&table_name)
.map_or(&[][..], std::vec::Vec::as_slice);
let mut columns_sorted: Vec<&Column> = columns.to_vec();
columns_sorted.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))
});
let pk_columns = table_pks.get(&table_name);
let unique_columns = single_unique_columns.get(&table_name);
let unique_constraints = table_uniques
.get(&table_name)
.map_or(&[][..], std::vec::Vec::as_slice);
let check_constraints = table_checks
.get(&table_name)
.map_or(&[][..], std::vec::Vec::as_slice);
let is_composite_pk = pk_columns.is_some_and(|pks| pks.len() > 1);
let ctx = TableGenContext {
table,
columns: &columns_sorted,
pk_columns,
unique_columns,
unique_constraints,
check_constraints,
is_composite_pk,
fk_map: &fk_map,
use_pub: options.use_pub,
field_casing: options.field_casing,
};
let table_code = generate_table_struct(&ctx);
code.push_str(&table_code);
code.push('\n');
result.tables.push(table_name);
}
for index in ddl.indexes.list() {
let index_code = generate_index_struct(index, options.use_pub, options.field_casing);
code.push_str(&index_code);
code.push('\n');
result.indexes.push(index.name.to_string());
}
for view in ddl.views.list() {
if view.is_existing {
continue;
}
let view_name = view.name.to_string();
let columns = table_columns
.get(&view_name)
.map_or(&[][..], std::vec::Vec::as_slice);
let view_code = generate_view_struct(view, columns, options.use_pub, options.field_casing);
code.push_str(&view_code);
code.push('\n');
result.views.push(view_name);
}
if options.include_schema {
let schema_code = generate_schema_struct(
&options.schema_name,
&result.tables,
&result.indexes,
options.use_pub,
options.field_casing,
);
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>>,
unique_constraints: &'a [&'a UniqueConstraint],
check_constraints: &'a [&'a CheckConstraint],
is_composite_pk: bool,
fk_map: &'a HashMap<(String, String), (&'a ForeignKey, usize)>,
use_pub: bool,
field_casing: FieldCasing,
}
fn generate_table_struct(ctx: &TableGenContext<'_>) -> String {
let mut code = String::new();
let vis = if ctx.use_pub { "pub " } else { "" };
let struct_name = ctx.table.name.to_pascal_case();
let needs_name_attr = apply_field_casing(&struct_name, ctx.field_casing) != ctx.table.name;
let mut table_attrs = Vec::new();
if needs_name_attr {
table_attrs.push(format!("name = \"{}\"", ctx.table.name));
}
if ctx.table.strict {
table_attrs.push("strict".to_string());
}
if ctx.table.without_rowid {
table_attrs.push("without_rowid".to_string());
}
for unique in ctx.unique_constraints {
if should_emit_table_unique(unique) {
table_attrs.push(format_table_unique_attr(unique, ctx.field_casing));
}
}
for (idx, check) in ctx.check_constraints.iter().enumerate() {
if check_column_target(check, ctx).is_none() {
table_attrs.push(format_table_check_attr(check, ctx, idx));
}
}
if table_attrs.is_empty() {
code.push_str("#[SQLiteTable]\n");
} else {
let _ = writeln!(code, "#[SQLiteTable({})]", table_attrs.join(", "));
}
let _ = writeln!(code, "{vis}struct {struct_name} {{");
for column in ctx.columns {
let field_code = generate_column_field(column, ctx);
code.push_str(&field_code);
}
code.push_str("}\n");
code
}
fn should_emit_table_unique(unique: &UniqueConstraint) -> bool {
unique.columns.len() > 1 || unique.name_explicit
}
fn default_unique_name(table: &str, columns: &[impl AsRef<str>]) -> String {
format!(
"{}_{}_unique",
table,
columns
.iter()
.map(AsRef::as_ref)
.collect::<Vec<_>>()
.join("_")
)
}
fn format_table_unique_attr(unique: &UniqueConstraint, field_casing: FieldCasing) -> String {
let columns: Vec<String> = unique
.columns
.iter()
.map(|col| apply_field_casing(col.as_ref(), field_casing))
.collect();
let mut args = vec![format!("columns({})", columns.join(", "))];
let default_name = default_unique_name(&unique.table, &unique.columns);
if unique.name_explicit || unique.name != default_name {
args.push(format!(
"name = \"{}\"",
escape_for_rust_literal(&unique.name)
));
}
format!("unique({})", args.join(", "))
}
fn format_table_check_attr(
check: &CheckConstraint,
_ctx: &TableGenContext<'_>,
_idx: usize,
) -> String {
let mut args = Vec::new();
args.push(format!(
"name = \"{}\"",
escape_for_rust_literal(&check.name)
));
args.push(format!(
"expr = \"{}\"",
escape_for_rust_literal(&check.value)
));
format!("check({})", args.join(", "))
}
fn check_column_target(check: &CheckConstraint, ctx: &TableGenContext<'_>) -> Option<String> {
let referenced = expression_referenced_columns(&check.value, ctx.columns);
if referenced.len() != 1 {
return None;
}
let column = referenced.into_iter().next()?;
if check.name == format!("{}_{}_check", ctx.table.name, column) {
Some(column)
} else {
None
}
}
fn expression_referenced_columns(expr: &str, columns: &[&Column]) -> Vec<String> {
columns
.iter()
.filter_map(|column| {
let name = column.name.as_ref();
if expression_references_identifier(expr, name) {
Some(name.to_string())
} else {
None
}
})
.collect()
}
fn expression_references_identifier(expr: &str, ident: &str) -> bool {
let expr_lower = expr.to_ascii_lowercase();
let ident_lower = ident.to_ascii_lowercase();
if expr_lower.contains(&format!("`{ident_lower}`"))
|| expr_lower.contains(&format!("\"{ident_lower}\""))
{
return true;
}
let mut offset = 0;
while let Some(pos) = expr_lower[offset..].find(&ident_lower) {
let start = offset + pos;
let end = start + ident_lower.len();
let before = expr_lower[..start].chars().next_back();
let after = expr_lower[end..].chars().next();
let before_boundary = before.is_none_or(|c| !(c == '_' || c.is_ascii_alphanumeric()));
let after_boundary = after.is_none_or(|c| !(c == '_' || c.is_ascii_alphanumeric()));
if before_boundary && after_boundary {
return true;
}
offset = end;
}
false
}
fn column_check_for<'a>(column: &Column, ctx: &TableGenContext<'a>) -> Option<&'a CheckConstraint> {
ctx.check_constraints
.iter()
.copied()
.find(|check| check_column_target(check, ctx).as_deref() == Some(column.name.as_ref()))
}
fn generate_column_field(column: &Column, ctx: &TableGenContext<'_>) -> String {
let vis = if ctx.use_pub { "pub " } else { "" };
let mut attrs = Vec::new();
let column_name = column.name.to_string();
let is_pk = ctx.pk_columns.is_some_and(|pks| pks.contains(&column_name));
if is_pk && !ctx.is_composite_pk {
attrs.push("primary".to_string());
}
if column.autoincrement == Some(true) {
attrs.push("autoincrement".to_string());
}
let is_unique = ctx
.unique_columns
.is_some_and(|uniques| uniques.contains(&column_name));
if is_unique {
attrs.push("unique".to_string());
}
if let Some(generated) = &column.generated {
use super::ddl::GeneratedType;
let gen_type = match generated.gen_type {
GeneratedType::Stored => "stored",
GeneratedType::Virtual => "virtual",
};
attrs.push(format!(
"generated({gen_type}, \"{}\")",
escape_for_rust_literal(&generated.expression)
));
}
if let Some(default) = &column.default
&& column.generated.is_none()
{
if let Some(d) = format_default_value(default, &column.sql_type) {
attrs.push(format!("default = {d}"));
} else if !default.trim().eq_ignore_ascii_case("null") {
attrs.push(format!(
"default_sql = \"{}\"",
escape_for_rust_literal(default)
));
}
}
if let Some(collate) = &column.collate {
attrs.push(format!(
"collate = \"{}\"",
escape_for_rust_literal(collate)
));
}
if let Some(check) = column_check_for(column, ctx) {
attrs.push(format!(
"check = \"{}\"",
escape_for_rust_literal(&check.value)
));
}
if let Some((fk, idx)) = ctx.fk_map.get(&(column.table.to_string(), column_name))
&& let Some(ref_col) = fk.columns_to.get(*idx)
{
let ref_table_struct = fk.table_to.to_pascal_case();
attrs.push(format!("references = {ref_table_struct}::{ref_col}"));
if let Some(on_delete) = &fk.on_delete
&& !on_delete.eq_ignore_ascii_case("NO ACTION")
{
let action = on_delete.replace(' ', "_").to_lowercase();
attrs.push(format!("on_delete = {action}"));
}
if let Some(on_update) = &fk.on_update
&& !on_update.eq_ignore_ascii_case("NO ACTION")
{
let action = on_update.replace(' ', "_").to_lowercase();
attrs.push(format!("on_update = {action}"));
}
}
let attr_str = if attrs.is_empty() {
String::new()
} else {
format!(" #[column({})]\n", attrs.join(", "))
};
let is_integer_pk =
is_pk && SQLTypeCategory::from_sql_type(&column.sql_type) == SQLTypeCategory::Integer;
let is_not_null = column.not_null || is_integer_pk;
let rust_type = sql_type_to_rust_type(&column.sql_type, is_not_null);
let field_name = apply_field_casing(column.name.as_ref(), ctx.field_casing);
format!("{attr_str} {vis}{field_name}: {rust_type},\n")
}
fn format_default_value(default: &str, sql_type: &str) -> Option<String> {
let default = default.trim();
let category = SQLTypeCategory::from_sql_type(sql_type);
if default.eq_ignore_ascii_case("null") {
return None;
}
if default.contains('(') && default.contains(')') {
return None;
}
match category {
SQLTypeCategory::Integer => {
if default == "0" || default == "1" {
return Some(default.to_string());
}
default.parse::<i64>().ok().map(|v| v.to_string())
}
SQLTypeCategory::Real => default.parse::<f64>().ok().map(|v| v.to_string()),
SQLTypeCategory::Text | SQLTypeCategory::Blob => {
let quoted = (default.starts_with('\'') && default.ends_with('\''))
|| (default.starts_with('"') && default.ends_with('"'));
if quoted {
let trimmed = default.trim_matches(|c| c == '\'' || c == '"');
Some(format!("\"{}\"", escape_for_rust_literal(trimmed)))
} else {
None
}
}
SQLTypeCategory::Numeric => default
.parse::<i64>()
.map(|v| v.to_string())
.ok()
.or_else(|| default.parse::<f64>().map(|v| v.to_string()).ok()),
}
}
fn sql_type_to_rust_type(sql_type: &str, not_null: bool) -> String {
if sql_type.eq_ignore_ascii_case("boolean") {
return if not_null {
"bool".to_string()
} else {
"Option<bool>".to_string()
};
}
let category = SQLTypeCategory::from_sql_type(sql_type);
let base_type = match category {
SQLTypeCategory::Integer | SQLTypeCategory::Numeric => "i64",
SQLTypeCategory::Real => "f64",
SQLTypeCategory::Text => "String",
SQLTypeCategory::Blob => "Vec<u8>",
};
if not_null {
base_type.to_string()
} else {
format!("Option<{base_type}>")
}
}
fn generate_index_struct(index: &Index, use_pub: bool, field_casing: FieldCasing) -> String {
let mut code = String::new();
let vis = if use_pub { "pub " } else { "" };
let struct_name = index.name.to_pascal_case();
let table_struct = index.table.to_pascal_case();
let columns: Vec<String> = index
.columns
.iter()
.map(|c| format!("{}::{}", table_struct, c.value))
.map(|s| {
if let Some((table, col)) = s.split_once("::") {
format!("{}::{}", table, apply_field_casing(col, field_casing))
} else {
s
}
})
.collect();
if index.is_unique {
code.push_str("#[SQLiteIndex(unique)]\n");
} else {
code.push_str("#[SQLiteIndex]\n");
}
let _ = writeln!(
code,
"{}struct {}({});",
vis,
struct_name,
columns.join(", ")
);
code
}
fn generate_view_struct(
view: &View,
columns: &[&Column],
use_pub: bool,
field_casing: FieldCasing,
) -> String {
let struct_name = view.name.to_pascal_case();
let vis = if use_pub { "pub " } else { "" };
let mut code = String::new();
let mut attrs = Vec::new();
if apply_field_casing(&struct_name, field_casing) != view.name.as_ref() {
attrs.push(format!("name = \"{}\"", view.name));
}
if let Some(def) = &view.definition {
let escaped_def = escape_for_rust_literal(def);
attrs.push(format!("definition = \"{escaped_def}\""));
}
if attrs.is_empty() {
code.push_str("#[SQLiteView]\n");
} else {
let _ = writeln!(code, "#[SQLiteView({})]", attrs.join(", "));
}
let _ = writeln!(code, "{vis}struct {struct_name} {{");
let mut sorted_columns: Vec<&&Column> = 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_name = apply_field_casing(column.name.as_ref(), field_casing);
let rust_type = sql_type_to_rust_type(&column.sql_type, column.not_null);
let _ = writeln!(code, " {vis}{field_name}: {rust_type},");
}
code.push_str("}\n");
code
}
fn generate_schema_struct(
schema_name: &str,
tables: &[String],
indexes: &[String],
use_pub: bool,
field_casing: FieldCasing,
) -> String {
let mut code = String::new();
let vis = if use_pub { "pub " } else { "" };
code.push_str("#[derive(SQLiteSchema)]\n");
let _ = writeln!(code, "{vis}struct {schema_name} {{");
for table in tables {
let field_name = apply_field_casing(table, field_casing);
let type_name = table.to_pascal_case();
let _ = writeln!(code, " {vis}{field_name}: {type_name},");
}
for index in indexes {
let field_name = apply_field_casing(index, field_casing);
let type_name = index.to_pascal_case();
let _ = writeln!(code, " {vis}{field_name}: {type_name},");
}
code.push_str("}\n");
code
}
#[cfg(test)]
mod tests {
use super::super::ddl::*;
use super::*;
#[test]
fn test_generate_simple_table() {
let mut ddl = SQLiteDDL::new();
ddl.tables.push(Table::new("users"));
ddl.columns.push(
Column::new("users", "id", "integer")
.not_null()
.autoincrement(),
);
ddl.columns
.push(Column::new("users", "name", "text").not_null());
ddl.columns.push(Column::new("users", "email", "text"));
ddl.pks.push(PrimaryKey::from_strings(
"users".to_string(),
"users_pk".to_string(),
vec!["id".to_string()],
));
let options = CodegenOptions {
include_schema: false,
schema_name: "AppSchema".to_string(),
use_pub: true,
..Default::default()
};
let result = generate_rust_schema(&ddl, &options);
assert_eq!(
result.code,
"\
//! Auto-generated SQLite schema from introspection
//!
use drizzle::sqlite::prelude::*;
#[SQLiteTable]
pub struct Users {
pub email: Option<String>,
#[column(primary, autoincrement)]
pub id: i64,
pub name: String,
}
"
);
assert_eq!(result.tables, vec!["users"]);
}
#[test]
fn test_generate_table_with_unique() {
let mut ddl = SQLiteDDL::new();
ddl.tables.push(Table::new("accounts"));
ddl.columns
.push(Column::new("accounts", "id", "integer").not_null());
ddl.columns
.push(Column::new("accounts", "email", "text").not_null());
ddl.uniques.push(UniqueConstraint::from_strings(
"accounts".to_string(),
"accounts_email_unique".to_string(),
vec!["email".to_string()],
));
let options = CodegenOptions::default();
let result = generate_rust_schema(&ddl, &options);
assert_eq!(
result.code,
"\
//! Auto-generated SQLite schema from introspection
//!
use drizzle::sqlite::prelude::*;
#[SQLiteTable]
struct Accounts {
#[column(unique)]
email: String,
id: i64,
}
"
);
}
#[test]
fn test_generate_table_with_foreign_key() {
let mut ddl = SQLiteDDL::new();
ddl.tables.push(Table::new("posts"));
ddl.columns
.push(Column::new("posts", "id", "integer").not_null());
ddl.columns
.push(Column::new("posts", "author_id", "integer").not_null());
let fk = ForeignKey::from_strings(
"posts".to_string(),
"fk_posts_author".to_string(),
vec!["author_id".to_string()],
"users".to_string(),
vec!["id".to_string()],
);
ddl.fks.push(fk);
let options = CodegenOptions::default();
let result = generate_rust_schema(&ddl, &options);
assert_eq!(
result.code,
"\
//! Auto-generated SQLite schema from introspection
//!
use drizzle::sqlite::prelude::*;
#[SQLiteTable]
struct Posts {
#[column(references = Users::id)]
author_id: i64,
id: i64,
}
"
);
}
#[test]
fn test_generate_index() {
let mut ddl = SQLiteDDL::new();
ddl.tables.push(Table::new("users"));
ddl.columns
.push(Column::new("users", "email", "text").not_null());
ddl.indexes.push(
Index::new(
"users",
"users_email_idx",
vec![IndexColumn {
value: "email".into(),
is_expression: false,
}],
)
.unique(),
);
let options = CodegenOptions::default();
let result = generate_rust_schema(&ddl, &options);
assert_eq!(
result.code,
"\
//! Auto-generated SQLite schema from introspection
//!
use drizzle::sqlite::prelude::*;
#[SQLiteTable]
struct Users {
email: String,
}
#[SQLiteIndex(unique)]
struct UsersEmailIdx(Users::email);
"
);
}
#[test]
fn test_generate_schema_struct() {
let mut ddl = SQLiteDDL::new();
ddl.tables.push(Table::new("users"));
ddl.tables.push(Table::new("posts"));
let options = CodegenOptions {
include_schema: true,
schema_name: "AppSchema".to_string(),
use_pub: true,
..Default::default()
};
let result = generate_rust_schema(&ddl, &options);
assert_eq!(
result.code,
"\
//! Auto-generated SQLite schema from introspection
//!
use drizzle::sqlite::prelude::*;
#[SQLiteTable]
pub struct Users {
}
#[SQLiteTable]
pub struct Posts {
}
#[derive(SQLiteSchema)]
pub struct AppSchema {
pub users: Users,
pub posts: Posts,
}
"
);
}
#[test]
fn test_sql_type_to_rust_type() {
assert_eq!(sql_type_to_rust_type("integer", true), "i64");
assert_eq!(sql_type_to_rust_type("integer", false), "Option<i64>");
assert_eq!(sql_type_to_rust_type("text", true), "String");
assert_eq!(sql_type_to_rust_type("text", false), "Option<String>");
assert_eq!(sql_type_to_rust_type("real", true), "f64");
assert_eq!(sql_type_to_rust_type("blob", true), "Vec<u8>");
assert_eq!(sql_type_to_rust_type("boolean", true), "bool");
assert_eq!(sql_type_to_rust_type("boolean", false), "Option<bool>");
assert_eq!(sql_type_to_rust_type("BOOLEAN", true), "bool");
}
}