use std::collections::BTreeSet;
use std::fs;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::admin::{Admin, AdminField, FieldType};
use crate::error::Error;
pub const SCHEMA_VERSION: u32 = 2;
pub const VALID_TYPE_NAMES: &[&str] = &["i32", "i64", "String", "bool", "DateTime"];
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Schema {
pub version: u32,
pub rustio_version: String,
pub models: Vec<SchemaModel>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SchemaModel {
pub name: String,
pub table: String,
pub admin_name: String,
pub display_name: String,
pub singular_name: String,
pub fields: Vec<SchemaField>,
pub relations: Vec<SchemaRelation>,
#[serde(default)]
pub core: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SchemaField {
pub name: String,
#[serde(rename = "type")]
pub ty: String,
pub nullable: bool,
pub editable: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub relation: Option<Relation>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Relation {
pub model: String,
pub field: String,
pub kind: RelationKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_field: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub required: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_delete: Option<String>,
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RelationKind {
BelongsTo,
HasMany,
}
impl RelationKind {
pub fn as_str(self) -> &'static str {
match self {
RelationKind::BelongsTo => "belongs_to",
RelationKind::HasMany => "has_many",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SchemaRelation {
pub kind: String,
pub to: String,
pub via: String,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq)]
pub enum SchemaError {
VersionMismatch { found: u32, expected: u32 },
DuplicateModel(String),
DuplicateField { model: String, field: String },
InvalidType {
model: String,
field: String,
ty: String,
},
UnknownRelationTarget { from: String, to: String },
EmptyIdentifier(&'static str),
Parse(String),
}
impl std::fmt::Display for SchemaError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::VersionMismatch { found, expected } => write!(
f,
"schema version mismatch: found {found}, expected {expected}"
),
Self::DuplicateModel(name) => write!(f, "duplicate model `{name}`"),
Self::DuplicateField { model, field } => {
write!(f, "duplicate field `{field}` in model `{model}`")
}
Self::InvalidType { model, field, ty } => write!(
f,
"field `{model}.{field}` has invalid type `{ty}` (valid: {valid})",
valid = VALID_TYPE_NAMES.join(", "),
),
Self::UnknownRelationTarget { from, to } => {
write!(f, "relation from `{from}` targets unknown model `{to}`")
}
Self::EmptyIdentifier(which) => write!(f, "empty {which}"),
Self::Parse(msg) => write!(f, "schema parse error: {msg}"),
}
}
}
impl std::error::Error for SchemaError {}
impl From<SchemaError> for Error {
fn from(e: SchemaError) -> Self {
Error::Internal(e.to_string())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IncomingRelation {
pub from_model: String,
pub from_field: String,
pub to_model: String,
pub kind: RelationKind,
}
impl Schema {
pub fn relation_for(&self, model: &str, field: &str) -> Option<&Relation> {
self.models
.iter()
.find(|m| m.name == model)?
.fields
.iter()
.find(|f| f.name == field)?
.relation
.as_ref()
}
pub fn incoming_relations(&self, model: &str) -> Vec<IncomingRelation> {
let mut out: Vec<IncomingRelation> = Vec::new();
for m in &self.models {
for f in &m.fields {
if let Some(rel) = &f.relation {
if rel.model == model && matches!(rel.kind, RelationKind::BelongsTo) {
out.push(IncomingRelation {
from_model: m.name.clone(),
from_field: f.name.clone(),
to_model: model.to_string(),
kind: RelationKind::HasMany,
});
}
}
}
}
out
}
pub fn from_admin(admin: &Admin) -> Self {
let mut models: Vec<SchemaModel> = admin
.entries()
.iter()
.map(SchemaModel::from_entry)
.collect();
models.sort_by(|a, b| a.name.cmp(&b.name));
Self {
version: SCHEMA_VERSION,
rustio_version: env!("CARGO_PKG_VERSION").to_string(),
models,
}
}
pub fn validate(&self) -> Result<(), SchemaError> {
if self.version != SCHEMA_VERSION {
return Err(SchemaError::VersionMismatch {
found: self.version,
expected: SCHEMA_VERSION,
});
}
let mut model_names: BTreeSet<&str> = BTreeSet::new();
for model in &self.models {
if model.name.is_empty() {
return Err(SchemaError::EmptyIdentifier("model name"));
}
if model.table.is_empty() {
return Err(SchemaError::EmptyIdentifier("model table"));
}
if !model_names.insert(model.name.as_str()) {
return Err(SchemaError::DuplicateModel(model.name.clone()));
}
}
let valid_types: BTreeSet<&str> = VALID_TYPE_NAMES.iter().copied().collect();
for model in &self.models {
let mut field_names: BTreeSet<&str> = BTreeSet::new();
for field in &model.fields {
if field.name.is_empty() {
return Err(SchemaError::EmptyIdentifier("field name"));
}
if !field_names.insert(field.name.as_str()) {
return Err(SchemaError::DuplicateField {
model: model.name.clone(),
field: field.name.clone(),
});
}
if !valid_types.contains(field.ty.as_str()) {
return Err(SchemaError::InvalidType {
model: model.name.clone(),
field: field.name.clone(),
ty: field.ty.clone(),
});
}
}
for relation in &model.relations {
if !model_names.contains(relation.to.as_str()) {
return Err(SchemaError::UnknownRelationTarget {
from: model.name.clone(),
to: relation.to.clone(),
});
}
}
}
Ok(())
}
pub fn parse(json: &str) -> Result<Self, SchemaError> {
let schema: Schema =
serde_json::from_str(json).map_err(|e| SchemaError::Parse(e.to_string()))?;
schema.validate()?;
Ok(schema)
}
pub fn to_pretty_json(&self) -> Result<String, Error> {
let mut out =
serde_json::to_string_pretty(self).map_err(|e| Error::Internal(e.to_string()))?;
out.push('\n');
Ok(out)
}
pub fn write_to(&self, path: &Path) -> Result<(), Error> {
self.validate()?;
let json = self.to_pretty_json()?;
let tmp = path.with_extension("json.tmp");
let _ = fs::remove_file(&tmp);
fs::write(&tmp, json).map_err(|e| Error::Internal(e.to_string()))?;
if let Err(e) = fs::rename(&tmp, path) {
let _ = fs::remove_file(&tmp);
return Err(Error::Internal(e.to_string()));
}
Ok(())
}
}
impl SchemaModel {
fn from_entry(entry: &crate::admin::AdminEntry) -> Self {
let mut fields: Vec<SchemaField> = entry
.fields
.iter()
.map(SchemaField::from_admin_field)
.collect();
fields.sort_by(|a, b| a.name.cmp(&b.name));
Self {
name: entry.singular_name.to_string(),
table: entry.table.to_string(),
admin_name: entry.admin_name.to_string(),
display_name: entry.display_name.to_string(),
singular_name: entry.singular_name.to_string(),
fields,
relations: Vec::new(),
core: entry.core,
}
}
}
impl SchemaField {
fn from_admin_field(f: &AdminField) -> Self {
let relation = f.relation.as_ref().map(|r| Relation {
model: r.target_model.to_string(),
field: "id".to_string(),
kind: RelationKind::BelongsTo,
display_field: r.display_field.map(|s| s.to_string()),
required: None,
on_delete: None,
});
Self {
name: f.name.to_string(),
ty: field_type_name(f.field_type).to_string(),
nullable: f.field_type.nullable(),
editable: f.editable,
relation,
}
}
}
pub(crate) fn field_type_name(ty: FieldType) -> &'static str {
match ty {
FieldType::I32 => "i32",
FieldType::I64 => "i64",
FieldType::String => "String",
FieldType::Bool => "bool",
FieldType::DateTime | FieldType::OptionalDateTime => "DateTime",
FieldType::OptionalI64 => "i64",
FieldType::OptionalString => "String",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::admin::{Admin, AdminField, AdminModel, FieldType};
use crate::http::FormData;
use crate::error::Error;
use crate::orm::{Model, Row, Value};
struct Post;
impl Model for Post {
const TABLE: &'static str = "posts";
const COLUMNS: &'static [&'static str] = &["id", "title", "published_at"];
const INSERT_COLUMNS: &'static [&'static str] = &["title", "published_at"];
fn id(&self) -> i64 {
0
}
fn from_row(_: Row<'_>) -> Result<Self, Error> {
unimplemented!()
}
fn insert_values(&self) -> Vec<Value> {
Vec::new()
}
}
impl AdminModel for Post {
const ADMIN_NAME: &'static str = "posts";
const DISPLAY_NAME: &'static str = "Posts";
const SINGULAR_NAME: &'static str = "Post";
const FIELDS: &'static [AdminField] = &[
AdminField {
name: "id",
label: "id",
field_type: FieldType::I64,
editable: false,
relation: None,
choices: None,
},
AdminField {
name: "title",
label: "title",
field_type: FieldType::String,
editable: true,
relation: None,
choices: None,
},
AdminField {
name: "published_at",
label: "published_at",
field_type: FieldType::OptionalDateTime,
editable: true,
relation: None,
choices: None,
},
];
fn display_values(&self) -> Vec<(String, String)> {
Vec::new()
}
fn from_form(_: &FormData) -> std::result::Result<Self, Vec<String>> {
unimplemented!()
}
fn object_label(&self) -> String {
"Post".into()
}
fn id(&self) -> i64 {
0
}
fn values_to_update(&self) -> Vec<(&'static str, Value)> {
Vec::new()
}
}
struct Book;
impl Model for Book {
const TABLE: &'static str = "books";
const COLUMNS: &'static [&'static str] = &["id", "title"];
const INSERT_COLUMNS: &'static [&'static str] = &["title"];
fn id(&self) -> i64 {
0
}
fn from_row(_: Row<'_>) -> Result<Self, Error> {
unimplemented!()
}
fn insert_values(&self) -> Vec<Value> {
Vec::new()
}
}
impl AdminModel for Book {
const ADMIN_NAME: &'static str = "books";
const DISPLAY_NAME: &'static str = "Books";
const SINGULAR_NAME: &'static str = "Book";
const FIELDS: &'static [AdminField] = &[
AdminField {
name: "id",
label: "id",
field_type: FieldType::I64,
editable: false,
relation: None,
choices: None,
},
AdminField {
name: "title",
label: "title",
field_type: FieldType::String,
editable: true,
relation: None,
choices: None,
},
];
fn display_values(&self) -> Vec<(String, String)> {
Vec::new()
}
fn from_form(_: &FormData) -> std::result::Result<Self, Vec<String>> {
unimplemented!()
}
fn object_label(&self) -> String {
"Book".into()
}
fn id(&self) -> i64 {
0
}
fn values_to_update(&self) -> Vec<(&'static str, Value)> {
Vec::new()
}
}
fn find<'a>(schema: &'a Schema, name: &str) -> &'a SchemaModel {
schema
.models
.iter()
.find(|m| m.name == name)
.unwrap_or_else(|| panic!("no model named `{name}` in schema"))
}
#[test]
fn schema_reflects_admin_registry() {
let admin = Admin::new().model::<Post>();
let schema = Schema::from_admin(&admin);
assert_eq!(schema.version, SCHEMA_VERSION);
assert_eq!(schema.models.len(), 2);
let m = find(&schema, "Post");
assert_eq!(m.table, "posts");
assert_eq!(m.admin_name, "posts");
assert_eq!(m.display_name, "Posts");
assert_eq!(m.singular_name, "Post");
assert_eq!(m.fields.len(), 3);
assert!(m.relations.is_empty());
assert!(!m.core, "user models must not be marked core");
let title = m.fields.iter().find(|f| f.name == "title").unwrap();
assert_eq!(title.ty, "String");
assert!(!title.nullable);
assert!(title.editable);
let pub_at = m.fields.iter().find(|f| f.name == "published_at").unwrap();
assert_eq!(pub_at.ty, "DateTime");
assert!(pub_at.nullable);
assert!(pub_at.editable);
}
#[test]
fn core_user_model_is_always_present() {
let schema = Schema::from_admin(&Admin::new());
let user = find(&schema, "User");
assert!(user.core, "User must be flagged as a core model");
assert_eq!(user.table, "rustio_users");
let pw = user
.fields
.iter()
.find(|f| f.name == "password_hash")
.unwrap();
assert!(
!pw.editable,
"password_hash must never be exposed as editable via admin"
);
let created_at = user.fields.iter().find(|f| f.name == "created_at").unwrap();
assert_eq!(created_at.ty, "DateTime");
assert!(!created_at.editable);
}
#[test]
fn schema_fields_are_sorted_by_name() {
let schema = Schema::from_admin(&Admin::new().model::<Post>());
let post = find(&schema, "Post");
let names: Vec<&str> = post.fields.iter().map(|f| f.name.as_str()).collect();
assert_eq!(names, vec!["id", "published_at", "title"]);
}
#[test]
fn schema_models_are_sorted_by_name() {
let schema = Schema::from_admin(&Admin::new().model::<Post>().model::<Book>());
let names: Vec<&str> = schema.models.iter().map(|m| m.name.as_str()).collect();
assert_eq!(names, vec!["Book", "Post", "User"]);
}
#[test]
fn to_pretty_json_round_trips() {
let schema = Schema::from_admin(&Admin::new().model::<Post>());
let json = schema.to_pretty_json().unwrap();
let parsed = Schema::parse(&json).unwrap();
assert_eq!(parsed, schema);
}
#[test]
fn to_pretty_json_ends_with_newline() {
let schema = Schema::from_admin(&Admin::new().model::<Post>());
let json = schema.to_pretty_json().unwrap();
assert!(json.ends_with('\n'), "schema JSON must end with newline");
}
#[test]
fn same_registry_produces_identical_bytes() {
let a = Schema::from_admin(&Admin::new().model::<Post>().model::<Book>())
.to_pretty_json()
.unwrap();
let b = Schema::from_admin(&Admin::new().model::<Post>().model::<Book>())
.to_pretty_json()
.unwrap();
assert_eq!(a, b);
}
#[test]
fn schema_snapshot_is_byte_for_byte_stable() {
let schema = Schema::from_admin(&Admin::new().model::<Post>());
let actual = schema.to_pretty_json().unwrap();
let expected = format!(
r#"{{
"version": {sv},
"rustio_version": "{rv}",
"models": [
{{
"name": "Post",
"table": "posts",
"admin_name": "posts",
"display_name": "Posts",
"singular_name": "Post",
"fields": [
{{
"name": "id",
"type": "i64",
"nullable": false,
"editable": false
}},
{{
"name": "published_at",
"type": "DateTime",
"nullable": true,
"editable": true
}},
{{
"name": "title",
"type": "String",
"nullable": false,
"editable": true
}}
],
"relations": [],
"core": false
}},
{{
"name": "User",
"table": "rustio_users",
"admin_name": "users",
"display_name": "Users",
"singular_name": "User",
"fields": [
{{
"name": "created_at",
"type": "DateTime",
"nullable": false,
"editable": false
}},
{{
"name": "email",
"type": "String",
"nullable": false,
"editable": true
}},
{{
"name": "id",
"type": "i64",
"nullable": false,
"editable": false
}},
{{
"name": "is_active",
"type": "bool",
"nullable": false,
"editable": true
}},
{{
"name": "password_hash",
"type": "String",
"nullable": false,
"editable": false
}},
{{
"name": "role",
"type": "String",
"nullable": false,
"editable": true
}}
],
"relations": [],
"core": true
}}
]
}}
"#,
rv = env!("CARGO_PKG_VERSION"),
sv = SCHEMA_VERSION,
);
assert_eq!(actual, expected);
}
#[test]
fn validate_accepts_clean_schema() {
let schema = Schema::from_admin(&Admin::new().model::<Post>().model::<Book>());
assert_eq!(schema.validate(), Ok(()));
}
#[test]
fn validate_rejects_version_mismatch() {
let mut schema = Schema::from_admin(&Admin::new().model::<Post>());
schema.version = 999;
assert_eq!(
schema.validate(),
Err(SchemaError::VersionMismatch {
found: 999,
expected: SCHEMA_VERSION
})
);
}
#[test]
fn validate_rejects_duplicate_models() {
let mut schema = Schema::from_admin(&Admin::new().model::<Post>());
let post = find(&schema, "Post").clone();
schema.models.push(post);
assert_eq!(
schema.validate(),
Err(SchemaError::DuplicateModel("Post".to_string()))
);
}
#[test]
fn validate_rejects_duplicate_fields() {
let mut schema = Schema::from_admin(&Admin::new().model::<Post>());
let post_idx = schema.models.iter().position(|m| m.name == "Post").unwrap();
let dup = schema.models[post_idx].fields[0].clone();
schema.models[post_idx].fields.push(dup);
assert_eq!(
schema.validate(),
Err(SchemaError::DuplicateField {
model: "Post".to_string(),
field: "id".to_string(),
})
);
}
#[test]
fn validate_rejects_unknown_type() {
let mut schema = Schema::from_admin(&Admin::new().model::<Post>());
let post_idx = schema.models.iter().position(|m| m.name == "Post").unwrap();
schema.models[post_idx].fields[0].ty = "HyperFloat128".to_string();
assert_eq!(
schema.validate(),
Err(SchemaError::InvalidType {
model: "Post".to_string(),
field: "id".to_string(),
ty: "HyperFloat128".to_string(),
})
);
}
#[test]
fn validate_rejects_dangling_relation() {
let mut schema = Schema::from_admin(&Admin::new().model::<Post>());
let post_idx = schema.models.iter().position(|m| m.name == "Post").unwrap();
schema.models[post_idx].relations.push(SchemaRelation {
kind: "belongs_to".to_string(),
to: "Ghost".to_string(),
via: "ghost_id".to_string(),
});
assert_eq!(
schema.validate(),
Err(SchemaError::UnknownRelationTarget {
from: "Post".to_string(),
to: "Ghost".to_string(),
})
);
}
#[test]
fn validate_accepts_self_referencing_relation() {
let mut schema = Schema::from_admin(&Admin::new().model::<Post>());
let post_idx = schema.models.iter().position(|m| m.name == "Post").unwrap();
schema.models[post_idx].relations.push(SchemaRelation {
kind: "belongs_to".to_string(),
to: "Post".to_string(),
via: "parent_id".to_string(),
});
assert_eq!(schema.validate(), Ok(()));
}
#[test]
fn parse_rejects_unknown_top_level_field() {
let bad = r#"{
"version": 1,
"rustio_version": "0.4.0",
"models": [],
"something_extra": true
}"#;
let result = Schema::parse(bad);
assert!(
matches!(result, Err(SchemaError::Parse(_))),
"unknown fields must be rejected, got: {:?}",
result
);
}
#[test]
fn parse_rejects_missing_required_field() {
let bad = r#"{
"version": 1,
"models": []
}"#;
let result = Schema::parse(bad);
assert!(
matches!(result, Err(SchemaError::Parse(_))),
"missing fields must be rejected"
);
}
#[test]
fn parse_rejects_version_mismatch() {
let bad = r#"{
"version": 999,
"rustio_version": "0.4.0",
"models": []
}"#;
let err = Schema::parse(bad).unwrap_err();
assert!(matches!(err, SchemaError::VersionMismatch { .. }));
}
#[test]
fn write_to_is_atomic_no_tmp_left_behind() {
let tmp_dir = std::env::temp_dir().join(format!(
"rustio-schema-write-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&tmp_dir).unwrap();
let target = tmp_dir.join("rustio.schema.json");
let schema = Schema::from_admin(&Admin::new().model::<Post>());
schema.write_to(&target).unwrap();
let bytes = std::fs::read_to_string(&target).unwrap();
let parsed = Schema::parse(&bytes).unwrap();
assert_eq!(parsed, schema);
assert!(!tmp_dir.join("rustio.schema.tmp").exists());
assert!(!tmp_dir.join("rustio.schema.json.tmp").exists());
std::fs::remove_dir_all(&tmp_dir).ok();
}
}