use crate::prelude::*;
use async_trait::async_trait;
use datafusion::prelude::*;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt::Debug;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ConstraintStatus {
Success,
Failure,
Skipped,
}
impl ConstraintStatus {
pub fn is_success(&self) -> bool {
matches!(self, ConstraintStatus::Success)
}
pub fn is_failure(&self) -> bool {
matches!(self, ConstraintStatus::Failure)
}
pub fn is_skipped(&self) -> bool {
matches!(self, ConstraintStatus::Skipped)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConstraintResult {
pub status: ConstraintStatus,
pub metric: Option<f64>,
pub message: Option<String>,
}
impl ConstraintResult {
pub fn success() -> Self {
Self {
status: ConstraintStatus::Success,
metric: None,
message: None,
}
}
pub fn success_with_metric(metric: f64) -> Self {
Self {
status: ConstraintStatus::Success,
metric: Some(metric),
message: None,
}
}
pub fn failure(message: impl Into<String>) -> Self {
Self {
status: ConstraintStatus::Failure,
metric: None,
message: Some(message.into()),
}
}
pub fn failure_with_metric(metric: f64, message: impl Into<String>) -> Self {
Self {
status: ConstraintStatus::Failure,
metric: Some(metric),
message: Some(message.into()),
}
}
pub fn skipped(message: impl Into<String>) -> Self {
Self {
status: ConstraintStatus::Skipped,
metric: None,
message: Some(message.into()),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ConstraintMetadata {
pub columns: Vec<String>,
pub description: Option<String>,
#[serde(skip_serializing_if = "HashMap::is_empty")]
pub custom: HashMap<String, String>,
}
impl ConstraintMetadata {
pub fn new() -> Self {
Self::default()
}
pub fn for_column(column: impl Into<String>) -> Self {
Self {
columns: vec![column.into()],
description: None,
custom: HashMap::new(),
}
}
pub fn for_columns<I, S>(columns: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self {
columns: columns.into_iter().map(Into::into).collect(),
description: None,
custom: HashMap::new(),
}
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn with_custom(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.custom.insert(key.into(), value.into());
self
}
}
#[async_trait]
pub trait Constraint: Debug + Send + Sync {
async fn evaluate(&self, ctx: &SessionContext) -> Result<ConstraintResult>;
fn name(&self) -> &str;
fn column(&self) -> Option<&str> {
None
}
fn description(&self) -> Option<&str> {
None
}
fn metadata(&self) -> ConstraintMetadata {
ConstraintMetadata::new()
}
}
pub type BoxedConstraint = Box<dyn Constraint>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_constraint_metadata_builder() {
let metadata = ConstraintMetadata::for_column("user_id")
.with_description("Checks user ID validity")
.with_custom("severity", "high")
.with_custom("category", "identity");
assert_eq!(metadata.columns, vec!["user_id"]);
assert_eq!(
metadata.description,
Some("Checks user ID validity".to_string())
);
assert_eq!(metadata.custom.get("severity"), Some(&"high".to_string()));
assert_eq!(
metadata.custom.get("category"),
Some(&"identity".to_string())
);
}
#[test]
fn test_constraint_metadata_multi_column() {
let metadata = ConstraintMetadata::for_columns(vec!["first_name", "last_name"])
.with_description("Checks name completeness");
assert_eq!(metadata.columns, vec!["first_name", "last_name"]);
assert_eq!(
metadata.description,
Some("Checks name completeness".to_string())
);
}
#[test]
fn test_constraint_result_builders() {
let success = ConstraintResult::success();
assert_eq!(success.status, ConstraintStatus::Success);
assert!(success.metric.is_none());
assert!(success.message.is_none());
let success_with_metric = ConstraintResult::success_with_metric(0.95);
assert_eq!(success_with_metric.status, ConstraintStatus::Success);
assert_eq!(success_with_metric.metric, Some(0.95));
let failure = ConstraintResult::failure("Validation failed");
assert_eq!(failure.status, ConstraintStatus::Failure);
assert_eq!(failure.message, Some("Validation failed".to_string()));
let failure_with_metric = ConstraintResult::failure_with_metric(0.3, "Below threshold");
assert_eq!(failure_with_metric.status, ConstraintStatus::Failure);
assert_eq!(failure_with_metric.metric, Some(0.3));
assert_eq!(
failure_with_metric.message,
Some("Below threshold".to_string())
);
let skipped = ConstraintResult::skipped("No data");
assert_eq!(skipped.status, ConstraintStatus::Skipped);
assert_eq!(skipped.message, Some("No data".to_string()));
}
}