use serde::{Deserialize, Serialize};
use crate::serve::content::ContentTypeId;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ContentSchema {
pub version: String,
pub content_type: ContentTypeId,
#[serde(default)]
pub fields: Vec<FieldDefinition>,
#[serde(default)]
pub required: Vec<String>,
#[serde(default)]
pub validators: Vec<ValidatorDefinition>,
}
impl ContentSchema {
pub fn new(content_type: ContentTypeId, version: impl Into<String>) -> Self {
Self {
version: version.into(),
content_type,
fields: Vec::new(),
required: Vec::new(),
validators: Vec::new(),
}
}
pub fn with_field(mut self, field: FieldDefinition) -> Self {
self.fields.push(field);
self
}
pub fn with_fields(mut self, fields: Vec<FieldDefinition>) -> Self {
self.fields.extend(fields);
self
}
pub fn with_required(mut self, field_name: impl Into<String>) -> Self {
self.required.push(field_name.into());
self
}
pub fn with_validator(mut self, validator: ValidatorDefinition) -> Self {
self.validators.push(validator);
self
}
pub fn get_field(&self, name: &str) -> Option<&FieldDefinition> {
self.fields.iter().find(|f| f.name == name)
}
pub fn is_required(&self, name: &str) -> bool {
self.required.contains(&name.to_string())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct FieldDefinition {
pub name: String,
pub field_type: FieldType,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub default: Option<serde_json::Value>,
#[serde(default)]
pub constraints: Vec<Constraint>,
#[serde(default)]
pub nullable: bool,
}
impl FieldDefinition {
pub fn new(name: impl Into<String>, field_type: FieldType) -> Self {
Self {
name: name.into(),
field_type,
description: None,
default: None,
constraints: Vec::new(),
nullable: false,
}
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn with_default(mut self, default: serde_json::Value) -> Self {
self.default = Some(default);
self
}
pub fn with_constraint(mut self, constraint: Constraint) -> Self {
self.constraints.push(constraint);
self
}
pub fn nullable(mut self) -> Self {
self.nullable = true;
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum FieldType {
String,
Integer,
Float,
Boolean,
DateTime,
Binary,
Array {
item_type: Box<Self>,
},
Object {
schema: Box<ContentSchema>,
},
Reference {
content_type: ContentTypeId,
},
}
impl FieldType {
pub fn string() -> Self {
Self::String
}
pub fn integer() -> Self {
Self::Integer
}
pub fn float() -> Self {
Self::Float
}
pub fn boolean() -> Self {
Self::Boolean
}
pub fn datetime() -> Self {
Self::DateTime
}
pub fn binary() -> Self {
Self::Binary
}
pub fn array(item_type: Self) -> Self {
Self::Array {
item_type: Box::new(item_type),
}
}
pub fn object(schema: ContentSchema) -> Self {
Self::Object {
schema: Box::new(schema),
}
}
pub fn reference(content_type: ContentTypeId) -> Self {
Self::Reference { content_type }
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Constraint {
Min {
value: f64,
},
Max {
value: f64,
},
MinLength {
value: usize,
},
MaxLength {
value: usize,
},
Pattern {
pattern: String,
},
Enum {
values: Vec<serde_json::Value>,
},
Custom {
name: String,
params: serde_json::Value,
},
}
impl Constraint {
pub fn min(value: f64) -> Self {
Self::Min { value }
}
pub fn max(value: f64) -> Self {
Self::Max { value }
}
pub fn min_length(value: usize) -> Self {
Self::MinLength { value }
}
pub fn max_length(value: usize) -> Self {
Self::MaxLength { value }
}
pub fn pattern(pattern: impl Into<String>) -> Self {
Self::Pattern {
pattern: pattern.into(),
}
}
pub fn enum_values(values: Vec<serde_json::Value>) -> Self {
Self::Enum { values }
}
pub fn custom(name: impl Into<String>, params: serde_json::Value) -> Self {
Self::Custom {
name: name.into(),
params,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ValidatorDefinition {
pub validator_type: String,
pub name: String,
pub message: String,
pub check: String,
}
impl ValidatorDefinition {
pub fn new(
validator_type: impl Into<String>,
name: impl Into<String>,
message: impl Into<String>,
check: impl Into<String>,
) -> Self {
Self {
validator_type: validator_type.into(),
name: name.into(),
message: message.into(),
check: check.into(),
}
}
pub fn custom(
name: impl Into<String>,
message: impl Into<String>,
check: impl Into<String>,
) -> Self {
Self::new("custom", name, message, check)
}
}
#[cfg(test)]
#[allow(clippy::float_cmp, clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_schema_creation() {
let schema = ContentSchema::new(ContentTypeId::dataset(), "1.0")
.with_field(FieldDefinition::new("name", FieldType::String))
.with_field(FieldDefinition::new("age", FieldType::Integer))
.with_required("name");
assert_eq!(schema.version, "1.0");
assert_eq!(schema.fields.len(), 2);
assert!(schema.is_required("name"));
assert!(!schema.is_required("age"));
}
#[test]
fn test_field_definition() {
let field = FieldDefinition::new("email", FieldType::String)
.with_description("User email address")
.with_constraint(Constraint::pattern(r"^[\w.-]+@[\w.-]+\.\w+$"))
.with_constraint(Constraint::max_length(255));
assert_eq!(field.name, "email");
assert_eq!(field.constraints.len(), 2);
}
#[test]
fn test_field_types() {
assert_eq!(FieldType::string(), FieldType::String);
assert_eq!(FieldType::integer(), FieldType::Integer);
let array_type = FieldType::array(FieldType::String);
match array_type {
FieldType::Array { item_type } => {
assert_eq!(*item_type, FieldType::String);
}
_ => panic!("Expected Array type"),
}
}
#[test]
fn test_constraints() {
let min = Constraint::min(0.0);
let max = Constraint::max(100.0);
let pattern = Constraint::pattern(r"^\d+$");
let enum_vals =
Constraint::enum_values(vec![serde_json::json!("a"), serde_json::json!("b")]);
assert!(matches!(min, Constraint::Min { value } if value == 0.0));
assert!(matches!(max, Constraint::Max { value } if value == 100.0));
assert!(matches!(pattern, Constraint::Pattern { .. }));
assert!(matches!(enum_vals, Constraint::Enum { .. }));
}
#[test]
fn test_validator_definition() {
let validator = ValidatorDefinition::custom(
"valid_email",
"Email must be valid",
"email matches /^[\\w.-]+@[\\w.-]+\\.\\w+$/",
);
assert_eq!(validator.validator_type, "custom");
assert_eq!(validator.name, "valid_email");
}
#[test]
fn test_nested_schema() {
let address_schema = ContentSchema::new(ContentTypeId::new("address"), "1.0")
.with_field(FieldDefinition::new("street", FieldType::String))
.with_field(FieldDefinition::new("city", FieldType::String));
let user_schema = ContentSchema::new(ContentTypeId::new("user"), "1.0")
.with_field(FieldDefinition::new("name", FieldType::String))
.with_field(FieldDefinition::new(
"address",
FieldType::object(address_schema),
));
assert_eq!(user_schema.fields.len(), 2);
match &user_schema.fields[1].field_type {
FieldType::Object { schema } => {
assert_eq!(schema.fields.len(), 2);
}
_ => panic!("Expected Object type"),
}
}
#[test]
fn test_get_field() {
let schema = ContentSchema::new(ContentTypeId::dataset(), "1.0")
.with_field(FieldDefinition::new("id", FieldType::Integer))
.with_field(FieldDefinition::new("name", FieldType::String));
assert!(schema.get_field("id").is_some());
assert!(schema.get_field("name").is_some());
assert!(schema.get_field("nonexistent").is_none());
}
#[test]
fn test_schema_with_fields() {
let fields = vec![
FieldDefinition::new("a", FieldType::String),
FieldDefinition::new("b", FieldType::Integer),
];
let schema = ContentSchema::new(ContentTypeId::dataset(), "1.0").with_fields(fields);
assert_eq!(schema.fields.len(), 2);
}
#[test]
fn test_schema_with_validator() {
let validator = ValidatorDefinition::new("custom", "test", "must be valid", "true");
let schema = ContentSchema::new(ContentTypeId::dataset(), "1.0").with_validator(validator);
assert_eq!(schema.validators.len(), 1);
}
#[test]
fn test_field_with_default() {
let field =
FieldDefinition::new("count", FieldType::Integer).with_default(serde_json::json!(0));
assert_eq!(field.default, Some(serde_json::json!(0)));
}
#[test]
fn test_field_nullable() {
let field = FieldDefinition::new("optional", FieldType::String).nullable();
assert!(field.nullable);
}
#[test]
fn test_field_type_boolean() {
assert_eq!(FieldType::boolean(), FieldType::Boolean);
}
#[test]
fn test_field_type_float() {
assert_eq!(FieldType::float(), FieldType::Float);
}
#[test]
fn test_field_type_datetime() {
assert_eq!(FieldType::datetime(), FieldType::DateTime);
}
#[test]
fn test_field_type_binary() {
assert_eq!(FieldType::binary(), FieldType::Binary);
}
#[test]
fn test_constraint_min_length() {
let c = Constraint::min_length(5);
assert!(matches!(c, Constraint::MinLength { value } if value == 5));
}
#[test]
fn test_validator_new() {
let v = ValidatorDefinition::new("regex", "email_check", "invalid email", r"^.+@.+$");
assert_eq!(v.validator_type, "regex");
assert_eq!(v.name, "email_check");
assert_eq!(v.message, "invalid email");
}
#[test]
fn test_field_type_reference() {
let ref_type = FieldType::reference(ContentTypeId::dataset());
match ref_type {
FieldType::Reference { content_type } => {
assert_eq!(content_type.as_str(), "alimentar.dataset");
}
_ => panic!("Expected Reference type"),
}
}
#[test]
fn test_constraint_custom() {
let c = Constraint::custom("unique", serde_json::json!({"scope": "global"}));
match c {
Constraint::Custom { name, params } => {
assert_eq!(name, "unique");
assert!(params.get("scope").is_some());
}
_ => panic!("Expected Custom constraint"),
}
}
#[test]
fn test_schema_serialization() {
let schema = ContentSchema::new(ContentTypeId::dataset(), "1.0")
.with_field(FieldDefinition::new("id", FieldType::Integer));
let json = serde_json::to_string(&schema);
assert!(json.is_ok());
let parsed: Result<ContentSchema, _> =
serde_json::from_str(&json.ok().unwrap_or_else(|| panic!("Should serialize")));
assert!(parsed.is_ok());
}
}