term_guard/core/
constraint.rs

1//! Constraint trait and related types for validation rules.
2
3use crate::prelude::*;
4use async_trait::async_trait;
5use datafusion::prelude::*;
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::fmt::Debug;
9
10/// The status of a constraint evaluation.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "lowercase")]
13pub enum ConstraintStatus {
14    /// The constraint check passed
15    Success,
16    /// The constraint check failed
17    Failure,
18    /// The constraint check was skipped (e.g., no data)
19    Skipped,
20}
21
22impl ConstraintStatus {
23    /// Returns true if this is a Success status.
24    pub fn is_success(&self) -> bool {
25        matches!(self, ConstraintStatus::Success)
26    }
27
28    /// Returns true if this is a Failure status.
29    pub fn is_failure(&self) -> bool {
30        matches!(self, ConstraintStatus::Failure)
31    }
32
33    /// Returns true if this is a Skipped status.
34    pub fn is_skipped(&self) -> bool {
35        matches!(self, ConstraintStatus::Skipped)
36    }
37}
38
39/// The result of evaluating a constraint.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct ConstraintResult {
42    /// The status of the constraint evaluation
43    pub status: ConstraintStatus,
44    /// Optional metric value computed during evaluation
45    pub metric: Option<f64>,
46    /// Optional message providing additional context
47    pub message: Option<String>,
48}
49
50impl ConstraintResult {
51    /// Creates a successful constraint result.
52    pub fn success() -> Self {
53        Self {
54            status: ConstraintStatus::Success,
55            metric: None,
56            message: None,
57        }
58    }
59
60    /// Creates a successful constraint result with a metric.
61    pub fn success_with_metric(metric: f64) -> Self {
62        Self {
63            status: ConstraintStatus::Success,
64            metric: Some(metric),
65            message: None,
66        }
67    }
68
69    /// Creates a failed constraint result.
70    pub fn failure(message: impl Into<String>) -> Self {
71        Self {
72            status: ConstraintStatus::Failure,
73            metric: None,
74            message: Some(message.into()),
75        }
76    }
77
78    /// Creates a failed constraint result with a metric.
79    pub fn failure_with_metric(metric: f64, message: impl Into<String>) -> Self {
80        Self {
81            status: ConstraintStatus::Failure,
82            metric: Some(metric),
83            message: Some(message.into()),
84        }
85    }
86
87    /// Creates a skipped constraint result.
88    pub fn skipped(message: impl Into<String>) -> Self {
89        Self {
90            status: ConstraintStatus::Skipped,
91            metric: None,
92            message: Some(message.into()),
93        }
94    }
95}
96
97/// Metadata associated with a constraint.
98///
99/// This struct provides extensible metadata that can be attached to constraints
100/// for better observability and reporting.
101#[derive(Debug, Clone, Default, Serialize, Deserialize)]
102pub struct ConstraintMetadata {
103    /// The column(s) this constraint operates on
104    pub columns: Vec<String>,
105    /// A human-readable description of what this constraint validates
106    pub description: Option<String>,
107    /// Additional key-value pairs for custom metadata
108    #[serde(skip_serializing_if = "HashMap::is_empty")]
109    pub custom: HashMap<String, String>,
110}
111
112impl ConstraintMetadata {
113    /// Creates a new metadata instance with no columns.
114    pub fn new() -> Self {
115        Self::default()
116    }
117
118    /// Creates metadata for a single column constraint.
119    pub fn for_column(column: impl Into<String>) -> Self {
120        Self {
121            columns: vec![column.into()],
122            description: None,
123            custom: HashMap::new(),
124        }
125    }
126
127    /// Creates metadata for a multi-column constraint.
128    pub fn for_columns<I, S>(columns: I) -> Self
129    where
130        I: IntoIterator<Item = S>,
131        S: Into<String>,
132    {
133        Self {
134            columns: columns.into_iter().map(Into::into).collect(),
135            description: None,
136            custom: HashMap::new(),
137        }
138    }
139
140    /// Sets the description.
141    pub fn with_description(mut self, description: impl Into<String>) -> Self {
142        self.description = Some(description.into());
143        self
144    }
145
146    /// Adds a custom metadata entry.
147    pub fn with_custom(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
148        self.custom.insert(key.into(), value.into());
149        self
150    }
151}
152
153/// A validation constraint that can be evaluated against data.
154///
155/// This trait defines the interface for all validation rules in the Term library.
156/// Implementations should be stateless and reusable across multiple validations.
157///
158/// # Examples
159///
160/// ```rust,ignore
161/// use term_guard::core::{Constraint, ConstraintResult, ConstraintMetadata};
162/// use async_trait::async_trait;
163///
164/// struct CompletenessConstraint {
165///     column: String,
166///     threshold: f64,
167/// }
168///
169/// #[async_trait]
170/// impl Constraint for CompletenessConstraint {
171///     async fn evaluate(&self, ctx: &SessionContext) -> Result<ConstraintResult> {
172///         // Implementation here
173///         Ok(ConstraintResult::success())
174///     }
175///
176///     fn name(&self) -> &str {
177///         "completeness"
178///     }
179///
180///     fn metadata(&self) -> ConstraintMetadata {
181///         ConstraintMetadata::for_column(&self.column)
182///             .with_description(format!("Checks completeness >= {}", self.threshold))
183///     }
184/// }
185/// ```
186#[async_trait]
187pub trait Constraint: Debug + Send + Sync {
188    /// Evaluates the constraint against the data in the session context.
189    ///
190    /// # Arguments
191    ///
192    /// * `ctx` - The DataFusion session context containing the data to validate
193    ///
194    /// # Returns
195    ///
196    /// A `Result` containing the constraint evaluation result or an error
197    async fn evaluate(&self, ctx: &SessionContext) -> Result<ConstraintResult>;
198
199    /// Returns the name of the constraint.
200    fn name(&self) -> &str;
201
202    /// Returns the column this constraint operates on (if single-column).
203    ///
204    /// Implementors should override this method if they operate on a single column.
205    /// The default implementation returns None.
206    fn column(&self) -> Option<&str> {
207        None
208    }
209
210    /// Returns a description of what this constraint validates.
211    ///
212    /// Implementors should override this method to provide a description.
213    /// The default implementation returns None.
214    fn description(&self) -> Option<&str> {
215        None
216    }
217
218    /// Returns the metadata associated with this constraint.
219    ///
220    /// The default implementation returns empty metadata for backward compatibility.
221    /// Implementors should override this method to provide meaningful metadata.
222    fn metadata(&self) -> ConstraintMetadata {
223        ConstraintMetadata::new()
224    }
225}
226
227/// A boxed constraint for use in collections.
228pub type BoxedConstraint = Box<dyn Constraint>;
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    #[test]
235    fn test_constraint_metadata_builder() {
236        let metadata = ConstraintMetadata::for_column("user_id")
237            .with_description("Checks user ID validity")
238            .with_custom("severity", "high")
239            .with_custom("category", "identity");
240
241        assert_eq!(metadata.columns, vec!["user_id"]);
242        assert_eq!(
243            metadata.description,
244            Some("Checks user ID validity".to_string())
245        );
246        assert_eq!(metadata.custom.get("severity"), Some(&"high".to_string()));
247        assert_eq!(
248            metadata.custom.get("category"),
249            Some(&"identity".to_string())
250        );
251    }
252
253    #[test]
254    fn test_constraint_metadata_multi_column() {
255        let metadata = ConstraintMetadata::for_columns(vec!["first_name", "last_name"])
256            .with_description("Checks name completeness");
257
258        assert_eq!(metadata.columns, vec!["first_name", "last_name"]);
259        assert_eq!(
260            metadata.description,
261            Some("Checks name completeness".to_string())
262        );
263    }
264
265    #[test]
266    fn test_constraint_result_builders() {
267        let success = ConstraintResult::success();
268        assert_eq!(success.status, ConstraintStatus::Success);
269        assert!(success.metric.is_none());
270        assert!(success.message.is_none());
271
272        let success_with_metric = ConstraintResult::success_with_metric(0.95);
273        assert_eq!(success_with_metric.status, ConstraintStatus::Success);
274        assert_eq!(success_with_metric.metric, Some(0.95));
275
276        let failure = ConstraintResult::failure("Validation failed");
277        assert_eq!(failure.status, ConstraintStatus::Failure);
278        assert_eq!(failure.message, Some("Validation failed".to_string()));
279
280        let failure_with_metric = ConstraintResult::failure_with_metric(0.3, "Below threshold");
281        assert_eq!(failure_with_metric.status, ConstraintStatus::Failure);
282        assert_eq!(failure_with_metric.metric, Some(0.3));
283        assert_eq!(
284            failure_with_metric.message,
285            Some("Below threshold".to_string())
286        );
287
288        let skipped = ConstraintResult::skipped("No data");
289        assert_eq!(skipped.status, ConstraintStatus::Skipped);
290        assert_eq!(skipped.message, Some("No data".to_string()));
291    }
292}