1use std::collections::BTreeSet;
28use std::fs;
29use std::path::Path;
30
31use serde::{Deserialize, Serialize};
32
33use crate::admin::{Admin, AdminField, FieldType};
34use crate::error::Error;
35
36pub const SCHEMA_VERSION: u32 = 2;
44
45pub const VALID_TYPE_NAMES: &[&str] = &["i32", "i64", "String", "bool", "DateTime"];
50
51#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
58#[serde(deny_unknown_fields)]
59pub struct Schema {
60 pub version: u32,
61 pub rustio_version: String,
62 pub models: Vec<SchemaModel>,
63}
64
65#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
66#[serde(deny_unknown_fields)]
67pub struct SchemaModel {
68 pub name: String,
69 pub table: String,
70 pub admin_name: String,
71 pub display_name: String,
72 pub singular_name: String,
73 pub fields: Vec<SchemaField>,
74 pub relations: Vec<SchemaRelation>,
77 #[serde(default)]
81 pub core: bool,
82}
83
84#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
85#[serde(deny_unknown_fields)]
86pub struct SchemaField {
87 pub name: String,
88 #[serde(rename = "type")]
89 pub ty: String,
90 pub nullable: bool,
91 pub editable: bool,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub relation: Option<Relation>,
99}
100
101#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
122#[serde(deny_unknown_fields)]
123pub struct Relation {
124 pub model: String,
126 pub field: String,
130 pub kind: RelationKind,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub display_field: Option<String>,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub required: Option<bool>,
148 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub on_delete: Option<String>,
153}
154
155#[non_exhaustive]
159#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
160#[serde(rename_all = "snake_case")]
161pub enum RelationKind {
162 BelongsTo,
163 HasMany,
164}
165
166impl RelationKind {
167 pub fn as_str(self) -> &'static str {
168 match self {
169 RelationKind::BelongsTo => "belongs_to",
170 RelationKind::HasMany => "has_many",
171 }
172 }
173}
174
175#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
181#[serde(deny_unknown_fields)]
182pub struct SchemaRelation {
183 pub kind: String,
184 pub to: String,
185 pub via: String,
186}
187
188#[non_exhaustive]
191#[derive(Debug, Clone, PartialEq)]
192pub enum SchemaError {
193 VersionMismatch { found: u32, expected: u32 },
195 DuplicateModel(String),
197 DuplicateField { model: String, field: String },
199 InvalidType {
201 model: String,
202 field: String,
203 ty: String,
204 },
205 UnknownRelationTarget { from: String, to: String },
207 EmptyIdentifier(&'static str),
210 Parse(String),
212}
213
214impl std::fmt::Display for SchemaError {
215 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
216 match self {
217 Self::VersionMismatch { found, expected } => write!(
218 f,
219 "schema version mismatch: found {found}, expected {expected}"
220 ),
221 Self::DuplicateModel(name) => write!(f, "duplicate model `{name}`"),
222 Self::DuplicateField { model, field } => {
223 write!(f, "duplicate field `{field}` in model `{model}`")
224 }
225 Self::InvalidType { model, field, ty } => write!(
226 f,
227 "field `{model}.{field}` has invalid type `{ty}` (valid: {valid})",
228 valid = VALID_TYPE_NAMES.join(", "),
229 ),
230 Self::UnknownRelationTarget { from, to } => {
231 write!(f, "relation from `{from}` targets unknown model `{to}`")
232 }
233 Self::EmptyIdentifier(which) => write!(f, "empty {which}"),
234 Self::Parse(msg) => write!(f, "schema parse error: {msg}"),
235 }
236 }
237}
238
239impl std::error::Error for SchemaError {}
240
241impl From<SchemaError> for Error {
242 fn from(e: SchemaError) -> Self {
243 Error::Internal(e.to_string())
244 }
245}
246
247#[derive(Debug, Clone, PartialEq, Eq)]
250pub struct IncomingRelation {
251 pub from_model: String,
253 pub from_field: String,
255 pub to_model: String,
257 pub kind: RelationKind,
260}
261
262impl Schema {
263 pub fn relation_for(&self, model: &str, field: &str) -> Option<&Relation> {
269 self.models
270 .iter()
271 .find(|m| m.name == model)?
272 .fields
273 .iter()
274 .find(|f| f.name == field)?
275 .relation
276 .as_ref()
277 }
278
279 pub fn incoming_relations(&self, model: &str) -> Vec<IncomingRelation> {
284 let mut out: Vec<IncomingRelation> = Vec::new();
285 for m in &self.models {
286 for f in &m.fields {
287 if let Some(rel) = &f.relation {
288 if rel.model == model && matches!(rel.kind, RelationKind::BelongsTo) {
289 out.push(IncomingRelation {
290 from_model: m.name.clone(),
291 from_field: f.name.clone(),
292 to_model: model.to_string(),
293 kind: RelationKind::HasMany,
294 });
295 }
296 }
297 }
298 }
299 out
300 }
301
302 pub fn from_admin(admin: &Admin) -> Self {
311 let mut models: Vec<SchemaModel> = admin
312 .entries()
313 .iter()
314 .map(SchemaModel::from_entry)
315 .collect();
316 models.sort_by(|a, b| a.name.cmp(&b.name));
317 Self {
318 version: SCHEMA_VERSION,
319 rustio_version: env!("CARGO_PKG_VERSION").to_string(),
320 models,
321 }
322 }
323
324 pub fn validate(&self) -> Result<(), SchemaError> {
329 if self.version != SCHEMA_VERSION {
330 return Err(SchemaError::VersionMismatch {
331 found: self.version,
332 expected: SCHEMA_VERSION,
333 });
334 }
335
336 let mut model_names: BTreeSet<&str> = BTreeSet::new();
337 for model in &self.models {
338 if model.name.is_empty() {
339 return Err(SchemaError::EmptyIdentifier("model name"));
340 }
341 if model.table.is_empty() {
342 return Err(SchemaError::EmptyIdentifier("model table"));
343 }
344 if !model_names.insert(model.name.as_str()) {
345 return Err(SchemaError::DuplicateModel(model.name.clone()));
346 }
347 }
348
349 let valid_types: BTreeSet<&str> = VALID_TYPE_NAMES.iter().copied().collect();
350
351 for model in &self.models {
352 let mut field_names: BTreeSet<&str> = BTreeSet::new();
353 for field in &model.fields {
354 if field.name.is_empty() {
355 return Err(SchemaError::EmptyIdentifier("field name"));
356 }
357 if !field_names.insert(field.name.as_str()) {
358 return Err(SchemaError::DuplicateField {
359 model: model.name.clone(),
360 field: field.name.clone(),
361 });
362 }
363 if !valid_types.contains(field.ty.as_str()) {
364 return Err(SchemaError::InvalidType {
365 model: model.name.clone(),
366 field: field.name.clone(),
367 ty: field.ty.clone(),
368 });
369 }
370 }
371
372 for relation in &model.relations {
373 if !model_names.contains(relation.to.as_str()) {
374 return Err(SchemaError::UnknownRelationTarget {
375 from: model.name.clone(),
376 to: relation.to.clone(),
377 });
378 }
379 }
380 }
381
382 Ok(())
383 }
384
385 pub fn parse(json: &str) -> Result<Self, SchemaError> {
390 let schema: Schema =
391 serde_json::from_str(json).map_err(|e| SchemaError::Parse(e.to_string()))?;
392 schema.validate()?;
393 Ok(schema)
394 }
395
396 pub fn to_pretty_json(&self) -> Result<String, Error> {
401 let mut out =
402 serde_json::to_string_pretty(self).map_err(|e| Error::Internal(e.to_string()))?;
403 out.push('\n');
404 Ok(out)
405 }
406
407 pub fn write_to(&self, path: &Path) -> Result<(), Error> {
411 self.validate()?;
412 let json = self.to_pretty_json()?;
413 let tmp = path.with_extension("json.tmp");
414 let _ = fs::remove_file(&tmp);
418 fs::write(&tmp, json).map_err(|e| Error::Internal(e.to_string()))?;
419 if let Err(e) = fs::rename(&tmp, path) {
420 let _ = fs::remove_file(&tmp);
423 return Err(Error::Internal(e.to_string()));
424 }
425 Ok(())
426 }
427}
428
429impl SchemaModel {
430 fn from_entry(entry: &crate::admin::AdminEntry) -> Self {
431 let mut fields: Vec<SchemaField> = entry
432 .fields
433 .iter()
434 .map(SchemaField::from_admin_field)
435 .collect();
436 fields.sort_by(|a, b| a.name.cmp(&b.name));
437 Self {
438 name: entry.singular_name.to_string(),
439 table: entry.table.to_string(),
440 admin_name: entry.admin_name.to_string(),
441 display_name: entry.display_name.to_string(),
442 singular_name: entry.singular_name.to_string(),
443 fields,
444 relations: Vec::new(),
445 core: entry.core,
446 }
447 }
448}
449
450impl SchemaField {
451 fn from_admin_field(f: &AdminField) -> Self {
452 let relation = f.relation.map(|r| Relation {
459 model: r.model.to_string(),
460 field: "id".to_string(),
461 kind: r.kind,
462 display_field: r.display_field.map(|s| s.to_string()),
463 required: None,
467 on_delete: None,
468 });
469 Self {
470 name: f.name.to_string(),
471 ty: field_type_name(f.ty).to_string(),
472 nullable: f.nullable,
473 editable: f.editable,
474 relation,
475 }
476 }
477}
478
479pub(crate) fn field_type_name(ty: FieldType) -> &'static str {
489 match ty {
490 FieldType::I32 => "i32",
491 FieldType::I64 => "i64",
492 FieldType::String => "String",
493 FieldType::Bool => "bool",
494 FieldType::DateTime => "DateTime",
495 }
496}
497
498#[cfg(test)]
499mod tests {
500 use super::*;
501 use crate::admin::{Admin, AdminField, AdminModel, FieldType, FormData};
502 use crate::error::Error;
503 use crate::orm::{Model, Row, Value};
504
505 struct Post;
506
507 impl Model for Post {
508 const TABLE: &'static str = "posts";
509 const COLUMNS: &'static [&'static str] = &["id", "title", "published_at"];
510 const INSERT_COLUMNS: &'static [&'static str] = &["title", "published_at"];
511 fn id(&self) -> i64 {
512 0
513 }
514 fn from_row(_: Row<'_>) -> Result<Self, Error> {
515 unimplemented!()
516 }
517 fn insert_values(&self) -> Vec<Value> {
518 Vec::new()
519 }
520 }
521
522 impl AdminModel for Post {
523 const ADMIN_NAME: &'static str = "posts";
524 const DISPLAY_NAME: &'static str = "Posts";
525 const FIELDS: &'static [AdminField] = &[
526 AdminField {
527 name: "id",
528 ty: FieldType::I64,
529 editable: false,
530 nullable: false,
531 relation: None,
532 },
533 AdminField {
534 name: "title",
535 ty: FieldType::String,
536 editable: true,
537 nullable: false,
538 relation: None,
539 },
540 AdminField {
541 name: "published_at",
542 ty: FieldType::DateTime,
543 editable: true,
544 nullable: true,
545 relation: None,
546 },
547 ];
548 fn singular_name() -> &'static str {
549 "Post"
550 }
551 fn field_display(&self, _: &str) -> Option<String> {
552 None
553 }
554 fn from_form(_: &FormData, _: Option<i64>) -> Result<Self, Error> {
555 unimplemented!()
556 }
557 }
558
559 struct Book;
563
564 impl Model for Book {
565 const TABLE: &'static str = "books";
566 const COLUMNS: &'static [&'static str] = &["id", "title"];
567 const INSERT_COLUMNS: &'static [&'static str] = &["title"];
568 fn id(&self) -> i64 {
569 0
570 }
571 fn from_row(_: Row<'_>) -> Result<Self, Error> {
572 unimplemented!()
573 }
574 fn insert_values(&self) -> Vec<Value> {
575 Vec::new()
576 }
577 }
578
579 impl AdminModel for Book {
580 const ADMIN_NAME: &'static str = "books";
581 const DISPLAY_NAME: &'static str = "Books";
582 const FIELDS: &'static [AdminField] = &[
583 AdminField {
584 name: "id",
585 ty: FieldType::I64,
586 editable: false,
587 nullable: false,
588 relation: None,
589 },
590 AdminField {
591 name: "title",
592 ty: FieldType::String,
593 editable: true,
594 nullable: false,
595 relation: None,
596 },
597 ];
598 fn singular_name() -> &'static str {
599 "Book"
600 }
601 fn field_display(&self, _: &str) -> Option<String> {
602 None
603 }
604 fn from_form(_: &FormData, _: Option<i64>) -> Result<Self, Error> {
605 unimplemented!()
606 }
607 }
608
609 fn find<'a>(schema: &'a Schema, name: &str) -> &'a SchemaModel {
613 schema
614 .models
615 .iter()
616 .find(|m| m.name == name)
617 .unwrap_or_else(|| panic!("no model named `{name}` in schema"))
618 }
619
620 #[test]
621 fn schema_reflects_admin_registry() {
622 let admin = Admin::new().model::<Post>();
623 let schema = Schema::from_admin(&admin);
624
625 assert_eq!(schema.version, SCHEMA_VERSION);
626 assert_eq!(schema.models.len(), 2);
628
629 let m = find(&schema, "Post");
630 assert_eq!(m.table, "posts");
631 assert_eq!(m.admin_name, "posts");
632 assert_eq!(m.display_name, "Posts");
633 assert_eq!(m.singular_name, "Post");
634 assert_eq!(m.fields.len(), 3);
635 assert!(m.relations.is_empty());
636 assert!(!m.core, "user models must not be marked core");
637
638 let title = m.fields.iter().find(|f| f.name == "title").unwrap();
639 assert_eq!(title.ty, "String");
640 assert!(!title.nullable);
641 assert!(title.editable);
642
643 let pub_at = m.fields.iter().find(|f| f.name == "published_at").unwrap();
644 assert_eq!(pub_at.ty, "DateTime");
645 assert!(pub_at.nullable);
646 assert!(pub_at.editable);
647 }
648
649 #[test]
650 fn core_user_model_is_always_present() {
651 let schema = Schema::from_admin(&Admin::new());
655 let user = find(&schema, "User");
656 assert!(user.core, "User must be flagged as a core model");
657 assert_eq!(user.table, "rustio_users");
658 let pw = user
659 .fields
660 .iter()
661 .find(|f| f.name == "password_hash")
662 .unwrap();
663 assert!(
664 !pw.editable,
665 "password_hash must never be exposed as editable via admin"
666 );
667 let created_at = user.fields.iter().find(|f| f.name == "created_at").unwrap();
670 assert_eq!(created_at.ty, "DateTime");
671 assert!(!created_at.editable);
672 }
673
674 #[test]
675 fn schema_fields_are_sorted_by_name() {
676 let schema = Schema::from_admin(&Admin::new().model::<Post>());
680 let post = find(&schema, "Post");
681 let names: Vec<&str> = post.fields.iter().map(|f| f.name.as_str()).collect();
682 assert_eq!(names, vec!["id", "published_at", "title"]);
683 }
684
685 #[test]
686 fn schema_models_are_sorted_by_name() {
687 let schema = Schema::from_admin(&Admin::new().model::<Post>().model::<Book>());
690 let names: Vec<&str> = schema.models.iter().map(|m| m.name.as_str()).collect();
691 assert_eq!(names, vec!["Book", "Post", "User"]);
692 }
693
694 #[test]
695 fn to_pretty_json_round_trips() {
696 let schema = Schema::from_admin(&Admin::new().model::<Post>());
697 let json = schema.to_pretty_json().unwrap();
698 let parsed = Schema::parse(&json).unwrap();
699 assert_eq!(parsed, schema);
700 }
701
702 #[test]
703 fn to_pretty_json_ends_with_newline() {
704 let schema = Schema::from_admin(&Admin::new().model::<Post>());
705 let json = schema.to_pretty_json().unwrap();
706 assert!(json.ends_with('\n'), "schema JSON must end with newline");
707 }
708
709 #[test]
710 fn same_registry_produces_identical_bytes() {
711 let a = Schema::from_admin(&Admin::new().model::<Post>().model::<Book>())
715 .to_pretty_json()
716 .unwrap();
717 let b = Schema::from_admin(&Admin::new().model::<Post>().model::<Book>())
718 .to_pretty_json()
719 .unwrap();
720 assert_eq!(a, b);
721 }
722
723 #[test]
731 fn schema_snapshot_is_byte_for_byte_stable() {
732 let schema = Schema::from_admin(&Admin::new().model::<Post>());
737 let actual = schema.to_pretty_json().unwrap();
738
739 let expected = format!(
740 r#"{{
741 "version": {sv},
742 "rustio_version": "{rv}",
743 "models": [
744 {{
745 "name": "Post",
746 "table": "posts",
747 "admin_name": "posts",
748 "display_name": "Posts",
749 "singular_name": "Post",
750 "fields": [
751 {{
752 "name": "id",
753 "type": "i64",
754 "nullable": false,
755 "editable": false
756 }},
757 {{
758 "name": "published_at",
759 "type": "DateTime",
760 "nullable": true,
761 "editable": true
762 }},
763 {{
764 "name": "title",
765 "type": "String",
766 "nullable": false,
767 "editable": true
768 }}
769 ],
770 "relations": [],
771 "core": false
772 }},
773 {{
774 "name": "User",
775 "table": "rustio_users",
776 "admin_name": "users",
777 "display_name": "Users",
778 "singular_name": "User",
779 "fields": [
780 {{
781 "name": "created_at",
782 "type": "DateTime",
783 "nullable": false,
784 "editable": false
785 }},
786 {{
787 "name": "email",
788 "type": "String",
789 "nullable": false,
790 "editable": true
791 }},
792 {{
793 "name": "id",
794 "type": "i64",
795 "nullable": false,
796 "editable": false
797 }},
798 {{
799 "name": "is_active",
800 "type": "bool",
801 "nullable": false,
802 "editable": true
803 }},
804 {{
805 "name": "password_hash",
806 "type": "String",
807 "nullable": false,
808 "editable": false
809 }},
810 {{
811 "name": "role",
812 "type": "String",
813 "nullable": false,
814 "editable": true
815 }}
816 ],
817 "relations": [],
818 "core": true
819 }}
820 ]
821}}
822"#,
823 rv = env!("CARGO_PKG_VERSION"),
824 sv = SCHEMA_VERSION,
825 );
826
827 assert_eq!(actual, expected);
828 }
829
830 #[test]
831 fn validate_accepts_clean_schema() {
832 let schema = Schema::from_admin(&Admin::new().model::<Post>().model::<Book>());
833 assert_eq!(schema.validate(), Ok(()));
834 }
835
836 #[test]
837 fn validate_rejects_version_mismatch() {
838 let mut schema = Schema::from_admin(&Admin::new().model::<Post>());
839 schema.version = 999;
840 assert_eq!(
841 schema.validate(),
842 Err(SchemaError::VersionMismatch {
843 found: 999,
844 expected: SCHEMA_VERSION
845 })
846 );
847 }
848
849 #[test]
850 fn validate_rejects_duplicate_models() {
851 let mut schema = Schema::from_admin(&Admin::new().model::<Post>());
852 let post = find(&schema, "Post").clone();
853 schema.models.push(post);
854 assert_eq!(
855 schema.validate(),
856 Err(SchemaError::DuplicateModel("Post".to_string()))
857 );
858 }
859
860 #[test]
861 fn validate_rejects_duplicate_fields() {
862 let mut schema = Schema::from_admin(&Admin::new().model::<Post>());
863 let post_idx = schema.models.iter().position(|m| m.name == "Post").unwrap();
864 let dup = schema.models[post_idx].fields[0].clone();
865 schema.models[post_idx].fields.push(dup);
866 assert_eq!(
867 schema.validate(),
868 Err(SchemaError::DuplicateField {
869 model: "Post".to_string(),
870 field: "id".to_string(),
871 })
872 );
873 }
874
875 #[test]
876 fn validate_rejects_unknown_type() {
877 let mut schema = Schema::from_admin(&Admin::new().model::<Post>());
878 let post_idx = schema.models.iter().position(|m| m.name == "Post").unwrap();
879 schema.models[post_idx].fields[0].ty = "HyperFloat128".to_string();
880 assert_eq!(
881 schema.validate(),
882 Err(SchemaError::InvalidType {
883 model: "Post".to_string(),
884 field: "id".to_string(),
885 ty: "HyperFloat128".to_string(),
886 })
887 );
888 }
889
890 #[test]
891 fn validate_rejects_dangling_relation() {
892 let mut schema = Schema::from_admin(&Admin::new().model::<Post>());
893 let post_idx = schema.models.iter().position(|m| m.name == "Post").unwrap();
894 schema.models[post_idx].relations.push(SchemaRelation {
895 kind: "belongs_to".to_string(),
896 to: "Ghost".to_string(),
897 via: "ghost_id".to_string(),
898 });
899 assert_eq!(
900 schema.validate(),
901 Err(SchemaError::UnknownRelationTarget {
902 from: "Post".to_string(),
903 to: "Ghost".to_string(),
904 })
905 );
906 }
907
908 #[test]
909 fn validate_accepts_self_referencing_relation() {
910 let mut schema = Schema::from_admin(&Admin::new().model::<Post>());
913 let post_idx = schema.models.iter().position(|m| m.name == "Post").unwrap();
914 schema.models[post_idx].relations.push(SchemaRelation {
915 kind: "belongs_to".to_string(),
916 to: "Post".to_string(),
917 via: "parent_id".to_string(),
918 });
919 assert_eq!(schema.validate(), Ok(()));
920 }
921
922 #[test]
923 fn parse_rejects_unknown_top_level_field() {
924 let bad = r#"{
925 "version": 1,
926 "rustio_version": "0.4.0",
927 "models": [],
928 "something_extra": true
929 }"#;
930 let result = Schema::parse(bad);
931 assert!(
932 matches!(result, Err(SchemaError::Parse(_))),
933 "unknown fields must be rejected, got: {:?}",
934 result
935 );
936 }
937
938 #[test]
939 fn parse_rejects_missing_required_field() {
940 let bad = r#"{
942 "version": 1,
943 "models": []
944 }"#;
945 let result = Schema::parse(bad);
946 assert!(
947 matches!(result, Err(SchemaError::Parse(_))),
948 "missing fields must be rejected"
949 );
950 }
951
952 #[test]
953 fn parse_rejects_version_mismatch() {
954 let bad = r#"{
955 "version": 999,
956 "rustio_version": "0.4.0",
957 "models": []
958 }"#;
959 let err = Schema::parse(bad).unwrap_err();
960 assert!(matches!(err, SchemaError::VersionMismatch { .. }));
961 }
962
963 #[test]
964 fn write_to_is_atomic_no_tmp_left_behind() {
965 let tmp_dir = std::env::temp_dir().join(format!(
966 "rustio-schema-write-{}-{}",
967 std::process::id(),
968 std::time::SystemTime::now()
969 .duration_since(std::time::UNIX_EPOCH)
970 .unwrap()
971 .as_nanos()
972 ));
973 std::fs::create_dir_all(&tmp_dir).unwrap();
974 let target = tmp_dir.join("rustio.schema.json");
975
976 let schema = Schema::from_admin(&Admin::new().model::<Post>());
977 schema.write_to(&target).unwrap();
978
979 let bytes = std::fs::read_to_string(&target).unwrap();
981 let parsed = Schema::parse(&bytes).unwrap();
982 assert_eq!(parsed, schema);
983
984 assert!(!tmp_dir.join("rustio.schema.tmp").exists());
987 assert!(!tmp_dir.join("rustio.schema.json.tmp").exists());
988
989 std::fs::remove_dir_all(&tmp_dir).ok();
990 }
991}