use cratefield_core::ConfigError;
use serde_json::Value;
use std::fmt::Write as _;
use crate::schema::{FieldDef, FieldKind, Schema, TableDef};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SqlDialect {
Sqlite,
Postgres,
}
impl SqlDialect {
pub const ALL: &'static [SqlDialect] = &[SqlDialect::Sqlite, SqlDialect::Postgres];
#[must_use]
pub fn name(self) -> &'static str {
match self {
SqlDialect::Sqlite => "sqlite",
SqlDialect::Postgres => "postgres",
}
}
fn column_type(self, kind: &FieldKind) -> &'static str {
match kind {
FieldKind::Text { .. }
| FieldKind::Timestamp
| FieldKind::Uuid
| FieldKind::Json
| FieldKind::Enum { .. } => "TEXT",
FieldKind::Integer { .. } => match self {
SqlDialect::Sqlite => "INTEGER",
SqlDialect::Postgres => "BIGINT",
},
FieldKind::Real { .. } => match self {
SqlDialect::Sqlite => "REAL",
SqlDialect::Postgres => "DOUBLE PRECISION",
},
FieldKind::Boolean => match self {
SqlDialect::Sqlite => "INTEGER",
SqlDialect::Postgres => "BOOLEAN",
},
}
}
}
#[must_use]
pub fn index_name(table: &str, field: &str) -> String {
format!("{table}_{field}_idx")
}
#[must_use]
pub(crate) fn needs_own_index(table: &TableDef, field: &FieldDef) -> bool {
field.indexed && !field.unique && !table.is_primary_key(&field.name)
}
fn depends_on<'a>(schema: &'a Schema, table: &'a TableDef) -> impl Iterator<Item = &'a str> {
table
.foreign_keys
.iter()
.map(|key| key.references.as_str())
.filter(move |name| *name != table.name && schema.table(name).is_some())
}
pub(crate) fn creation_order(schema: &Schema) -> (Vec<&TableDef>, Vec<&TableDef>) {
let mut done = vec![false; schema.tables.len()];
let mut created: Vec<&str> = Vec::with_capacity(schema.tables.len());
let mut order: Vec<&TableDef> = Vec::with_capacity(schema.tables.len());
while let Some((index, table)) = schema
.tables
.iter()
.enumerate()
.filter(|(index, table)| {
!done[*index] && depends_on(schema, table).all(|name| created.contains(&name))
})
.min_by_key(|(_, table)| table.name.as_str())
{
done[index] = true;
created.push(&table.name);
order.push(table);
}
let cyclic = schema
.tables
.iter()
.enumerate()
.filter(|(index, _)| !done[*index])
.map(|(_, table)| table)
.collect();
(order, cyclic)
}
impl Schema {
pub fn ddl(&self, dialect: SqlDialect) -> Result<String, ConfigError> {
self.validate()?;
let mut out = format!(
"-- Generated by cratefield-tables from the venture manifest.\n\
-- Dialect: {}. Forward-only: this file only creates.\n",
dialect.name()
);
let (ordered, _) = creation_order(self);
for table in ordered {
out.push('\n');
out.push_str(&self.create_table_sql(table, dialect));
for index in create_index_sql(table) {
out.push_str(&index);
}
}
Ok(out)
}
fn create_table_sql(&self, table: &TableDef, dialect: SqlDialect) -> String {
let mut parts: Vec<String> = table
.fields
.iter()
.map(|field| column_sql(table, field, dialect))
.collect();
parts.push(format!("PRIMARY KEY ({})", table.primary_key.join(", ")));
for key in &table.foreign_keys {
let column = self
.table(&key.references)
.and_then(|target| target.primary_key.first())
.map_or("id", String::as_str);
parts.push(format!(
"FOREIGN KEY ({}) REFERENCES {} ({column})",
key.field, key.references
));
}
format!(
"CREATE TABLE IF NOT EXISTS {} (\n {}\n);\n",
table.name,
parts.join(",\n ")
)
}
}
fn column_sql(table: &TableDef, field: &FieldDef, dialect: SqlDialect) -> String {
let mut sql = format!("{} {}", field.name, dialect.column_type(&field.kind));
if field.required || table.is_primary_key(&field.name) {
sql.push_str(" NOT NULL");
}
if field.unique {
sql.push_str(" UNIQUE");
}
if let Some(default) = &field.default {
sql.push_str(" DEFAULT ");
sql.push_str(&literal(&field.kind, default, dialect));
}
if let FieldKind::Enum { values } = &field.kind {
let allowed: Vec<String> = values.iter().map(|value| quoted(value)).collect();
let _ = write!(sql, " CHECK ({} IN ({}))", field.name, allowed.join(", "));
}
sql
}
fn create_index_sql(table: &TableDef) -> Vec<String> {
table
.fields
.iter()
.filter(|field| needs_own_index(table, field))
.map(|field| {
format!(
"CREATE INDEX IF NOT EXISTS {} ON {} ({});\n",
index_name(&table.name, &field.name),
table.name,
field.name
)
})
.collect()
}
fn quoted(text: &str) -> String {
format!("'{}'", text.replace('\'', "''"))
}
fn literal(kind: &FieldKind, value: &Value, dialect: SqlDialect) -> String {
match kind {
FieldKind::Boolean => match (value.as_bool(), dialect) {
(Some(true), SqlDialect::Sqlite) => "1".to_owned(),
(Some(false), SqlDialect::Sqlite) => "0".to_owned(),
(Some(true), SqlDialect::Postgres) => "TRUE".to_owned(),
(Some(false), SqlDialect::Postgres) => "FALSE".to_owned(),
(None, _) => quoted(&value.to_string()),
},
FieldKind::Integer { .. } => crate::value::integral(value)
.map_or_else(|| quoted(&value.to_string()), |integer| integer.to_string()),
FieldKind::Real { .. } => value.as_f64().map_or_else(
|| quoted(&value.to_string()),
|number| {
if number.is_finite() && number.fract() == 0.0 {
format!("{number:.1}")
} else {
number.to_string()
}
},
),
FieldKind::Json => quoted(&value.to_string()),
FieldKind::Text { .. }
| FieldKind::Timestamp
| FieldKind::Uuid
| FieldKind::Enum { .. } => value
.as_str()
.map_or_else(|| quoted(&value.to_string()), quoted),
}
}