pjson_rs_domain/value_objects/
schema.rs

1//! Schema value object for JSON validation
2//!
3//! Represents a JSON schema definition following a subset of JSON Schema specification.
4//! This is a domain value object with no identity, defined purely by its attributes.
5
6use serde::{Deserialize, Serialize};
7use smallvec::SmallVec;
8use std::collections::HashMap;
9
10use crate::DomainError;
11
12/// Schema identifier for tracking and referencing schemas
13#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
14pub struct SchemaId(String);
15
16impl SchemaId {
17    /// Create a new schema identifier
18    ///
19    /// # Arguments
20    /// * `id` - Unique schema identifier string
21    ///
22    /// # Returns
23    /// New schema ID instance
24    ///
25    /// # Examples
26    /// ```
27    /// # use pjson_rs_domain::value_objects::SchemaId;
28    /// let schema_id = SchemaId::new("user-profile-v1");
29    /// ```
30    pub fn new(id: impl Into<String>) -> Self {
31        Self(id.into())
32    }
33
34    /// Get schema ID as string slice
35    pub fn as_str(&self) -> &str {
36        &self.0
37    }
38}
39
40impl std::fmt::Display for SchemaId {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        write!(f, "{}", self.0)
43    }
44}
45
46/// JSON Schema representation for validation
47///
48/// Supports a practical subset of JSON Schema Draft 2020-12 focused on
49/// validation needs for streaming JSON data.
50///
51/// # Design Philosophy
52/// - Focused on validation, not full JSON Schema specification
53/// - Performance-oriented with pre-compiled validation rules
54/// - Zero-copy where possible using Arc for shared data
55/// - Type-safe with strongly-typed enum variants
56///
57/// # Examples
58/// ```
59/// # use pjson_rs_domain::value_objects::{Schema, SchemaType};
60/// let schema = Schema::Object {
61///     properties: vec![
62///         ("id".to_string(), Schema::Integer { minimum: Some(1), maximum: None }),
63///         ("name".to_string(), Schema::String {
64///             min_length: Some(1),
65///             max_length: Some(100),
66///             pattern: None,
67///             allowed_values: None,
68///         }),
69///     ].into_iter().collect(),
70///     required: vec!["id".to_string(), "name".to_string()],
71///     additional_properties: false,
72/// };
73/// ```
74#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
75pub enum Schema {
76    /// String type with optional constraints
77    String {
78        /// Minimum string length (inclusive)
79        min_length: Option<usize>,
80        /// Maximum string length (inclusive)
81        max_length: Option<usize>,
82        /// Pattern to match (regex)
83        pattern: Option<String>,
84        /// Enumeration of allowed values
85        allowed_values: Option<SmallVec<[String; 8]>>,
86    },
87
88    /// Integer type with optional range constraints
89    Integer {
90        /// Minimum value (inclusive)
91        minimum: Option<i64>,
92        /// Maximum value (inclusive)
93        maximum: Option<i64>,
94    },
95
96    /// Number type (float/double) with optional range constraints
97    Number {
98        /// Minimum value (inclusive)
99        minimum: Option<f64>,
100        /// Maximum value (inclusive)
101        maximum: Option<f64>,
102    },
103
104    /// Boolean type (no constraints)
105    Boolean,
106
107    /// Null type (no constraints)
108    Null,
109
110    /// Array type with element schema and size constraints
111    Array {
112        /// Schema for array elements (None = any type)
113        items: Option<Box<Schema>>,
114        /// Minimum array length (inclusive)
115        min_items: Option<usize>,
116        /// Maximum array length (inclusive)
117        max_items: Option<usize>,
118        /// Whether all items must be unique
119        unique_items: bool,
120    },
121
122    /// Object type with property schemas
123    Object {
124        /// Property name to schema mapping
125        properties: HashMap<String, Schema>,
126        /// List of required property names
127        required: Vec<String>,
128        /// Whether additional properties are allowed
129        additional_properties: bool,
130    },
131
132    /// Union type (one of multiple schemas)
133    OneOf {
134        /// List of possible schemas
135        schemas: SmallVec<[Box<Schema>; 4]>,
136    },
137
138    /// Intersection type (all of multiple schemas)
139    AllOf {
140        /// List of schemas that must all match
141        schemas: SmallVec<[Box<Schema>; 4]>,
142    },
143
144    /// Any type (no validation)
145    Any,
146}
147
148/// Schema validation result
149pub type SchemaValidationResult<T> = Result<T, SchemaValidationError>;
150
151/// Schema validation error with detailed context
152///
153/// Provides rich error information including the JSON path where validation failed,
154/// expected vs actual values, and human-readable error messages.
155///
156/// # Design
157/// - Includes full path context for nested validation failures
158/// - Provides actionable error messages for debugging
159/// - Zero-allocation for common error cases using `String`
160#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, thiserror::Error)]
161pub enum SchemaValidationError {
162    /// Type mismatch error
163    #[error("Type mismatch at '{path}': expected {expected}, got {actual}")]
164    TypeMismatch {
165        /// JSON path where error occurred
166        path: String,
167        /// Expected type
168        expected: String,
169        /// Actual type
170        actual: String,
171    },
172
173    /// Missing required field
174    #[error("Missing required field at '{path}': {field}")]
175    MissingRequired {
176        /// JSON path to parent object
177        path: String,
178        /// Name of missing field
179        field: String,
180    },
181
182    /// Value out of range
183    #[error("Value out of range at '{path}': {value} not in [{min}, {max}]")]
184    OutOfRange {
185        /// JSON path where error occurred
186        path: String,
187        /// Actual value
188        value: String,
189        /// Minimum allowed value
190        min: String,
191        /// Maximum allowed value
192        max: String,
193    },
194
195    /// String length constraint violation
196    #[error("String length constraint at '{path}': length {actual} not in [{min}, {max}]")]
197    StringLengthConstraint {
198        /// JSON path where error occurred
199        path: String,
200        /// Actual string length
201        actual: usize,
202        /// Minimum allowed length
203        min: usize,
204        /// Maximum allowed length
205        max: usize,
206    },
207
208    /// Pattern mismatch
209    #[error("Pattern mismatch at '{path}': value '{value}' does not match pattern '{pattern}'")]
210    PatternMismatch {
211        /// JSON path where error occurred
212        path: String,
213        /// Actual value
214        value: String,
215        /// Expected pattern
216        pattern: String,
217    },
218
219    /// Array size constraint violation
220    #[error("Array size constraint at '{path}': size {actual} not in [{min}, {max}]")]
221    ArraySizeConstraint {
222        /// JSON path where error occurred
223        path: String,
224        /// Actual array size
225        actual: usize,
226        /// Minimum allowed size
227        min: usize,
228        /// Maximum allowed size
229        max: usize,
230    },
231
232    /// Unique items constraint violation
233    #[error("Unique items constraint at '{path}': duplicate items found")]
234    DuplicateItems {
235        /// JSON path where error occurred
236        path: String,
237    },
238
239    /// Invalid enum value
240    #[error("Invalid enum value at '{path}': '{value}' not in allowed values")]
241    InvalidEnumValue {
242        /// JSON path where error occurred
243        path: String,
244        /// Actual value
245        value: String,
246    },
247
248    /// Additional properties not allowed
249    #[error("Additional property not allowed at '{path}': '{property}'")]
250    AdditionalPropertyNotAllowed {
251        /// JSON path where error occurred
252        path: String,
253        /// Property name
254        property: String,
255    },
256
257    /// No matching schema in OneOf
258    #[error("No matching schema in OneOf at '{path}'")]
259    NoMatchingOneOf {
260        /// JSON path where error occurred
261        path: String,
262    },
263
264    /// Not all schemas match in AllOf
265    #[error("Not all schemas match in AllOf at '{path}': {failures}")]
266    AllOfFailure {
267        /// JSON path where error occurred
268        path: String,
269        /// List of failing schema indices
270        failures: String,
271    },
272}
273
274impl Schema {
275    /// Check if schema allows a specific type
276    ///
277    /// Used for quick type compatibility checks before full validation.
278    ///
279    /// # Arguments
280    /// * `schema_type` - The type to check compatibility for
281    ///
282    /// # Returns
283    /// `true` if the schema allows the type, `false` otherwise
284    pub fn allows_type(&self, schema_type: SchemaType) -> bool {
285        match (self, schema_type) {
286            (Self::String { .. }, SchemaType::String) => true,
287            (Self::Integer { .. }, SchemaType::Integer) => true,
288            (Self::Number { .. }, SchemaType::Number) => true,
289            (Self::Boolean, SchemaType::Boolean) => true,
290            (Self::Null, SchemaType::Null) => true,
291            (Self::Array { .. }, SchemaType::Array) => true,
292            (Self::Object { .. }, SchemaType::Object) => true,
293            (Self::Any, _) => true,
294            (Self::OneOf { schemas }, schema_type) => {
295                schemas.iter().any(|s| s.allows_type(schema_type))
296            }
297            (Self::AllOf { schemas }, schema_type) => {
298                schemas.iter().all(|s| s.allows_type(schema_type))
299            }
300            _ => false,
301        }
302    }
303
304    /// Get estimated validation cost for performance optimization
305    ///
306    /// Higher cost indicates more expensive validation operations.
307    /// Used by validation scheduler to optimize validation order.
308    ///
309    /// # Returns
310    /// Validation cost estimate (0-1000 range)
311    pub fn validation_cost(&self) -> usize {
312        match self {
313            Self::Null | Self::Boolean | Self::Any => 1,
314            Self::Integer { .. } | Self::Number { .. } => 5,
315            Self::String {
316                pattern: Some(_), ..
317            } => 50, // Regex is expensive
318            Self::String { .. } => 10,
319            Self::Array { items, .. } => {
320                let item_cost = items.as_ref().map_or(1, |s| s.validation_cost());
321                10 + item_cost
322            }
323            Self::Object { properties, .. } => {
324                let prop_cost: usize = properties.values().map(|s| s.validation_cost()).sum();
325                20 + prop_cost
326            }
327            Self::OneOf { schemas } => {
328                let max_cost = schemas
329                    .iter()
330                    .map(|s| s.validation_cost())
331                    .max()
332                    .unwrap_or(0);
333                30 + max_cost * schemas.len()
334            }
335            Self::AllOf { schemas } => {
336                let total_cost: usize = schemas.iter().map(|s| s.validation_cost()).sum();
337                20 + total_cost
338            }
339        }
340    }
341
342    /// Create a simple string schema with length constraints
343    pub fn string(min_length: Option<usize>, max_length: Option<usize>) -> Self {
344        Self::String {
345            min_length,
346            max_length,
347            pattern: None,
348            allowed_values: None,
349        }
350    }
351
352    /// Create a simple integer schema with range constraints
353    pub fn integer(minimum: Option<i64>, maximum: Option<i64>) -> Self {
354        Self::Integer { minimum, maximum }
355    }
356
357    /// Create a simple number schema with range constraints
358    pub fn number(minimum: Option<f64>, maximum: Option<f64>) -> Self {
359        Self::Number { minimum, maximum }
360    }
361
362    /// Create an array schema with item type
363    pub fn array(items: Option<Schema>) -> Self {
364        Self::Array {
365            items: items.map(Box::new),
366            min_items: None,
367            max_items: None,
368            unique_items: false,
369        }
370    }
371
372    /// Create an object schema with properties
373    pub fn object(properties: HashMap<String, Schema>, required: Vec<String>) -> Self {
374        Self::Object {
375            properties,
376            required,
377            additional_properties: true,
378        }
379    }
380}
381
382/// Simplified schema type for quick type checking
383#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
384pub enum SchemaType {
385    /// String type
386    String,
387    /// Integer type
388    Integer,
389    /// Floating-point number type
390    Number,
391    /// Boolean type
392    Boolean,
393    /// Null type
394    Null,
395    /// Array type
396    Array,
397    /// Object type
398    Object,
399}
400
401impl From<&Schema> for SchemaType {
402    fn from(schema: &Schema) -> Self {
403        match schema {
404            Schema::String { .. } => Self::String,
405            Schema::Integer { .. } => Self::Integer,
406            Schema::Number { .. } => Self::Number,
407            Schema::Boolean => Self::Boolean,
408            Schema::Null => Self::Null,
409            Schema::Array { .. } => Self::Array,
410            Schema::Object { .. } => Self::Object,
411            Schema::Any => Self::Object, // Default to most flexible
412            Schema::OneOf { .. } | Schema::AllOf { .. } => Self::Object,
413        }
414    }
415}
416
417impl From<DomainError> for SchemaValidationError {
418    fn from(error: DomainError) -> Self {
419        match error {
420            DomainError::ValidationError(msg) => Self::TypeMismatch {
421                path: "/".to_string(),
422                expected: "valid".to_string(),
423                actual: msg,
424            },
425            _ => Self::TypeMismatch {
426                path: "/".to_string(),
427                expected: "valid".to_string(),
428                actual: error.to_string(),
429            },
430        }
431    }
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437
438    #[test]
439    fn test_schema_id_creation() {
440        let id = SchemaId::new("test-schema-v1");
441        assert_eq!(id.as_str(), "test-schema-v1");
442        assert_eq!(id.to_string(), "test-schema-v1");
443    }
444
445    #[test]
446    fn test_schema_allows_type() {
447        let string_schema = Schema::string(Some(1), Some(100));
448        assert!(string_schema.allows_type(SchemaType::String));
449        assert!(!string_schema.allows_type(SchemaType::Integer));
450
451        let any_schema = Schema::Any;
452        assert!(any_schema.allows_type(SchemaType::String));
453        assert!(any_schema.allows_type(SchemaType::Integer));
454    }
455
456    #[test]
457    fn test_validation_cost() {
458        let simple = Schema::Boolean;
459        assert_eq!(simple.validation_cost(), 1);
460
461        let complex = Schema::Object {
462            properties: [
463                ("id".to_string(), Schema::integer(None, None)),
464                ("name".to_string(), Schema::string(Some(1), Some(100))),
465            ]
466            .into_iter()
467            .collect(),
468            required: vec!["id".to_string()],
469            additional_properties: false,
470        };
471        assert!(complex.validation_cost() > 20);
472    }
473
474    #[test]
475    fn test_schema_builders() {
476        let str_schema = Schema::string(Some(1), Some(100));
477        assert!(matches!(str_schema, Schema::String { .. }));
478
479        let int_schema = Schema::integer(Some(0), Some(100));
480        assert!(matches!(int_schema, Schema::Integer { .. }));
481
482        let arr_schema = Schema::array(Some(Schema::integer(None, None)));
483        assert!(matches!(arr_schema, Schema::Array { .. }));
484    }
485}