use super::design::{Design, Entity, FieldType, ModuleDesign, OnDelete};
use super::mounting;
use crate::db::Db;
use crate::db::sea_orm::{ConnectionTrait, DatabaseBackend, Statement};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::Path;
const SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Serialize, Deserialize)]
pub struct SchemaContract {
pub schema_version: u32,
pub tables: Vec<Table>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Table {
pub name: String,
pub module: String,
pub columns: Vec<Column>,
pub foreign_keys: Vec<ForeignKeyRef>,
pub unique: Vec<Vec<String>>,
pub indexes: Vec<String>,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub enums: BTreeMap<String, Vec<String>>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Column {
pub name: String,
pub r#type: String,
pub nullable: bool,
pub pk: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ForeignKeyRef {
pub column: String,
pub references: TableColumn,
pub on_delete: String,
pub enforced: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct TableColumn {
pub table: String,
pub column: String,
}
fn for_each_entity<'a>(m: &'a ModuleDesign, top: &'a str, f: &mut impl FnMut(&'a str, &'a Entity)) {
for e in &m.entities {
f(top, e);
}
for sub in &m.subroutes {
for_each_entity(sub, top, f);
}
}
struct DesignIndex<'a> {
design: &'a Design,
entities: BTreeMap<String, (&'a str, &'a Entity)>,
membership: Option<(String, String)>,
tenant_key_string: bool,
}
impl<'a> DesignIndex<'a> {
fn build(design: &'a Design) -> Self {
let mut entities = BTreeMap::new();
for m in &design.modules {
for_each_entity(m, &m.name, &mut |top, e| {
entities.insert(design.table_name(&e.name), (top, e));
});
}
let mut membership = None;
let mut tenant_key_string = false;
if let Some(tenancy) = &design.tenancy {
let members = format!("{}_members", Design::to_snake(&tenancy.entity));
let module = design
.modules
.iter()
.find(|m| m.entities.iter().any(|e| e.name == tenancy.entity))
.map(|m| m.name.clone())
.unwrap_or_default();
membership = Some((members, module));
tenant_key_string = design.target_key_rust_type(&tenancy.entity) == "String";
}
Self {
design,
entities,
membership,
tenant_key_string,
}
}
}
fn overlay_type(index: &DesignIndex, table: &str, column: &str) -> Option<String> {
if let Some((members, _)) = &index.membership
&& members == table
{
return Some(match column {
"id" => "integer".to_string(),
"user_id" | "role" => "string".to_string(),
_ => {
if index.tenant_key_string {
"string".to_string()
} else {
"integer".to_string()
}
}
});
}
let (_, entity) = index.entities.get(table)?;
if let Some(field) = entity.fields.iter().find(|f| f.name == column) {
return Some(field_type_name(field.field_type));
}
for b in &entity.belongs_to {
if Design::fk_column(&b.entity) == column {
return Some(
if entity_owner_key_is_string(index, &b.entity) {
"string"
} else {
"integer"
}
.to_string(),
);
}
}
None
}
fn entity_owner_key_is_string(index: &DesignIndex, target: &str) -> bool {
index
.entities
.get(&index.design.table_name(target))
.and_then(|(_, e)| e.fields.iter().find(|f| f.name == "id"))
.map(|f| f.field_type == FieldType::String)
.unwrap_or(false)
}
fn field_type_name(t: FieldType) -> String {
match t {
FieldType::String => "string",
FieldType::Integer => "integer",
FieldType::Float => "float",
FieldType::Boolean => "boolean",
FieldType::Datetime => "datetime",
FieldType::Uuid => "uuid",
FieldType::Json => "json",
}
.to_string()
}
fn fallback_type(sqlite_decl: &str) -> String {
let up = sqlite_decl.to_ascii_uppercase();
if up.contains("INT") {
"integer"
} else if up.contains("REAL") || up.contains("DOUBLE") || up.contains("FLOAT") {
"float"
} else if up.contains("BOOL") {
"boolean"
} else {
"string"
}
.to_string()
}
fn on_delete_token(policy: OnDelete) -> String {
match policy {
OnDelete::Cascade => "cascade",
OnDelete::SetNull => "set_null",
OnDelete::Restrict => "restrict",
}
.to_string()
}
fn normalize_on_delete(raw: &str) -> String {
match raw.to_ascii_uppercase().as_str() {
"CASCADE" => "cascade",
"SET NULL" => "set_null",
"RESTRICT" => "restrict",
_ => "no_action",
}
.to_string()
}
fn pragma_int(row: &crate::db::sea_orm::QueryResult, col: &str) -> Result<i64, String> {
row.try_get::<i64>("", col)
.or_else(|_| row.try_get::<i32>("", col).map(i64::from))
.map_err(|e| format!("pragma column `{col}`: {e}"))
}
async fn query(db: &Db, sql: &str) -> Result<Vec<crate::db::sea_orm::QueryResult>, String> {
db.conn()
.query_all(Statement::from_string(DatabaseBackend::Sqlite, sql))
.await
.map_err(|e| format!("query `{sql}`: {e}"))
}
pub async fn derive_schema(root: &Path, design: &Design) -> Result<SchemaContract, String> {
let migrations = mounting::collect_migrations(root)?;
let db = Db::connect("sqlite::memory:")
.await
.map_err(|e| format!("connect sqlite::memory:: {}", e.message()))?;
db.migrate_owned(&migrations)
.await
.map_err(|e| format!("apply migrations: {}", e.message()))?;
let index = DesignIndex::build(design);
let table_rows = query(
&db,
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name",
)
.await?;
let mut table_names: Vec<String> = Vec::new();
for row in &table_rows {
let name: String = row
.try_get("", "name")
.map_err(|e| format!("sqlite_master name: {e}"))?;
if name == "_jerrycan_migrations" || name.starts_with("sqlite_") {
continue;
}
table_names.push(name);
}
table_names.sort();
let mut tables = Vec::new();
for table in &table_names {
tables.push(introspect_table(&db, &index, table).await?);
}
Ok(SchemaContract {
schema_version: SCHEMA_VERSION,
tables,
})
}
async fn introspect_table(db: &Db, index: &DesignIndex<'_>, table: &str) -> Result<Table, String> {
let info = query(db, &format!("PRAGMA table_info(\"{table}\")")).await?;
let mut columns = Vec::new();
for row in &info {
let name: String = row
.try_get("", "name")
.map_err(|e| format!("table_info name: {e}"))?;
let decl: String = row.try_get("", "type").unwrap_or_default();
let notnull = pragma_int(row, "notnull")? != 0;
let pk = pragma_int(row, "pk")? > 0;
let r#type = overlay_type(index, table, &name).unwrap_or_else(|| fallback_type(&decl));
columns.push(Column {
name,
r#type,
nullable: !notnull,
pk,
});
}
let fk_rows = query(db, &format!("PRAGMA foreign_key_list(\"{table}\")")).await?;
let mut foreign_keys = Vec::new();
for row in &fk_rows {
let column: String = row
.try_get("", "from")
.map_err(|e| format!("foreign_key_list from: {e}"))?;
let ref_table: String = row
.try_get("", "table")
.map_err(|e| format!("foreign_key_list table: {e}"))?;
let ref_column: String = row.try_get("", "to").unwrap_or_else(|_| "id".to_string());
let on_delete: String = row.try_get("", "on_delete").unwrap_or_default();
foreign_keys.push(ForeignKeyRef {
column,
references: TableColumn {
table: ref_table,
column: ref_column,
},
on_delete: normalize_on_delete(&on_delete),
enforced: true, });
}
if let Some((_, entity)) = index.entities.get(table) {
for b in &entity.belongs_to {
let col = Design::fk_column(&b.entity);
if foreign_keys.iter().any(|f| f.column == col) {
continue; }
foreign_keys.push(ForeignKeyRef {
column: col,
references: TableColumn {
table: index.design.table_name(&b.entity),
column: "id".to_string(),
},
on_delete: on_delete_token(b.on_delete),
enforced: false,
});
}
}
foreign_keys.sort_by(|a, b| a.column.cmp(&b.column));
let mut unique: Vec<Vec<String>> = Vec::new();
let mut indexes: Vec<String> = Vec::new();
let idx_rows = query(db, &format!("PRAGMA index_list(\"{table}\")")).await?;
for row in &idx_rows {
let idx_name: String = row
.try_get("", "name")
.map_err(|e| format!("index_list name: {e}"))?;
let is_unique = pragma_int(row, "unique")? != 0;
let origin: String = row.try_get("", "origin").unwrap_or_default();
if origin == "pk" {
continue;
}
if is_unique {
let cols = index_columns(db, &idx_name).await?;
unique.push(cols);
} else {
indexes.push(idx_name);
}
}
unique.sort();
indexes.sort();
let module = if let Some((members, tenant_module)) = &index.membership {
if members == table {
tenant_module.clone()
} else {
index
.entities
.get(table)
.map(|(m, _)| (*m).to_string())
.unwrap_or_default()
}
} else {
index
.entities
.get(table)
.map(|(m, _)| (*m).to_string())
.unwrap_or_default()
};
let mut enums = BTreeMap::new();
if let Some((_, entity)) = index.entities.get(table) {
for f in &entity.fields {
if let Some(values) = &f.values {
enums.insert(f.name.clone(), values.clone());
}
}
}
Ok(Table {
name: table.to_string(),
module,
columns,
foreign_keys,
unique,
indexes,
enums,
})
}
async fn index_columns(db: &Db, idx_name: &str) -> Result<Vec<String>, String> {
let rows = query(db, &format!("PRAGMA index_info(\"{idx_name}\")")).await?;
let mut cols = Vec::new();
for row in &rows {
let name: String = row.try_get("", "name").unwrap_or_default();
if !name.is_empty() {
cols.push(name);
}
}
Ok(cols)
}
pub fn render(contract: &SchemaContract) -> String {
let mut s = serde_json::to_string_pretty(contract).expect("contract serializes");
s.push('\n');
s
}
pub fn write_schema(root: &Path, design: &Design) -> Result<Option<String>, String> {
if !design.wants_db() {
return Ok(None);
}
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| e.to_string())?;
let contract = runtime.block_on(derive_schema(root, design))?;
std::fs::write(root.join("schema.json"), render(&contract))
.map_err(|e| format!("write schema.json: {e}"))?;
Ok(Some("schema.json".to_string()))
}
pub async fn verify_fresh(
root: &Path,
design: &Design,
) -> Result<Vec<super::checkpipe::Diagnostic>, String> {
let derived = render(&derive_schema(root, design).await?);
let committed = std::fs::read_to_string(root.join("schema.json")).unwrap_or_default();
if committed == derived {
return Ok(Vec::new());
}
Ok(vec![super::checkpipe::Diagnostic {
code: "JC0520".into(),
file: Some("schema.json".into()),
line: Some(1),
message: "schema.json does not match the schema derived from the module migrations".into(),
suggestion: Some("run jerrycan schema --write".into()),
doc_url: Some("jerrycan docs database".into()),
}])
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn schema_contract_reflects_migrations_and_design_types() {
let s = include_str!("../../../../conformance/designs/reference-slice.design.json");
let d: Design = serde_json::from_str(s).unwrap();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("app");
super::super::scaffold::scaffold(&root, &d).unwrap();
let contract = derive_schema(&root, &d).await.unwrap();
let leads = contract.tables.iter().find(|t| t.name == "leads").unwrap();
assert_eq!(leads.module, "leads");
let phone = leads.columns.iter().find(|c| c.name == "phone").unwrap();
assert_eq!(phone.r#type, "string"); assert!(!phone.nullable);
let custom = leads.columns.iter().find(|c| c.name == "custom").unwrap();
assert_eq!(custom.r#type, "json");
assert!(custom.nullable);
let ws_fk = leads
.foreign_keys
.iter()
.find(|f| f.column == "workspace_id")
.unwrap();
assert_eq!(ws_fk.references.table, "workspaces");
assert_eq!(ws_fk.on_delete, "cascade");
assert!(
!ws_fk.enforced,
"cross-module relation is application-enforced, not a DB FK"
);
assert!(leads.indexes.iter().any(|i| i.contains("workspace_id")));
assert!(leads.unique.iter().any(|u| u == &vec!["phone".to_string()]));
assert!(leads.indexes.iter().any(|i| i.contains("phone")));
let members = contract
.tables
.iter()
.find(|t| t.name == "workspace_members")
.unwrap();
assert_eq!(members.module, "workspaces");
let member_fk = members
.foreign_keys
.iter()
.find(|f| f.on_delete == "cascade")
.unwrap();
assert!(
member_fk.enforced,
"the membership table keeps a real, introspected FK constraint"
);
let names: Vec<_> = contract.tables.iter().map(|t| t.name.clone()).collect();
let mut sorted = names.clone();
sorted.sort();
assert_eq!(names, sorted);
}
#[test]
fn published_schema_pins_the_contract_shape() {
let s = include_str!("../../../../docs/contracts/db-schema.json");
let v: serde_json::Value = serde_json::from_str(s).unwrap();
assert_eq!(v["$id"], "https://jerrycan.cc/schemas/db-schema-v1.json");
assert_eq!(
v["properties"]["schema_version"]["const"]
.as_u64()
.expect("schema_version const"),
u64::from(SCHEMA_VERSION),
);
let required: Vec<&str> = v["required"]
.as_array()
.expect("required")
.iter()
.filter_map(|x| x.as_str())
.collect();
assert_eq!(required, ["schema_version", "tables"]);
}
}