1use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
12#[serde(rename_all = "snake_case")]
13pub enum OnFailure {
14 Quarantine,
16 QuarantineBatch,
18 Abort,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
24#[serde(rename_all = "snake_case")]
25pub enum CompareOp {
26 Gt,
28 Gte,
30 Lt,
32 Lte,
34 Eq,
38 Ne,
41}
42
43impl std::fmt::Display for CompareOp {
44 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45 f.write_str(match self {
46 CompareOp::Gt => "gt",
47 CompareOp::Gte => "gte",
48 CompareOp::Lt => "lt",
49 CompareOp::Lte => "lte",
50 CompareOp::Eq => "eq",
51 CompareOp::Ne => "ne",
52 })
53 }
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
58#[serde(rename_all = "snake_case")]
59pub enum JsonType {
60 Boolean,
62 Number,
64 String,
66 Array,
68 Object,
70 Null,
72}
73
74impl std::fmt::Display for JsonType {
75 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76 f.write_str(match self {
77 JsonType::Boolean => "boolean",
78 JsonType::Number => "number",
79 JsonType::String => "string",
80 JsonType::Array => "array",
81 JsonType::Object => "object",
82 JsonType::Null => "null",
83 })
84 }
85}
86
87fn default_true() -> bool {
88 true
89}
90
91#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
95pub struct QualitySpec {
96 #[serde(default)]
98 pub record: Vec<RecordCheck>,
99 #[serde(default)]
101 pub batch: Vec<BatchCheck>,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
107#[serde(tag = "type", rename_all = "snake_case")]
108pub enum RecordCheck {
109 NotNull {
111 field: String,
112 #[serde(default = "default_true")]
115 treat_missing_as_null: bool,
116 on_failure: OnFailure,
117 },
118 NotEmpty {
120 field: String,
121 on_failure: OnFailure,
122 },
123 RegexMatch {
125 field: String,
126 pattern: String,
127 on_failure: OnFailure,
128 },
129 ValueInSet {
131 field: String,
132 values: Vec<Value>,
133 on_failure: OnFailure,
134 },
135 NotInSet {
137 field: String,
138 values: Vec<Value>,
139 on_failure: OnFailure,
140 },
141 Compare {
143 field: String,
144 op: CompareOp,
145 value: Value,
146 on_failure: OnFailure,
147 },
148 TypeIs {
150 field: String,
151 expected: JsonType,
152 on_failure: OnFailure,
153 },
154 StringLength {
156 field: String,
157 #[serde(default)]
158 min: Option<usize>,
159 #[serde(default)]
160 max: Option<usize>,
161 on_failure: OnFailure,
162 },
163 #[cfg(feature = "quality-jsonschema")]
165 JsonSchema {
166 schema: Value,
167 on_failure: OnFailure,
168 },
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
174#[serde(tag = "type", rename_all = "snake_case")]
175pub enum BatchCheck {
176 RowCount {
178 #[serde(default)]
179 min: Option<usize>,
180 #[serde(default)]
181 max: Option<usize>,
182 on_failure: OnFailure,
183 },
184 NullRate {
186 field: String,
187 max: f64,
189 on_failure: OnFailure,
190 },
191 Unique {
193 fields: Vec<String>,
194 on_failure: OnFailure,
195 },
196 DistinctCount {
198 field: String,
199 #[serde(default)]
200 min: Option<usize>,
201 #[serde(default)]
202 max: Option<usize>,
203 on_failure: OnFailure,
204 },
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 #[test]
212 fn on_failure_serializes_snake_case() {
213 assert_eq!(
214 serde_json::to_string(&OnFailure::QuarantineBatch).unwrap(),
215 "\"quarantine_batch\""
216 );
217 }
218
219 #[test]
220 fn compare_op_round_trips() {
221 let op: CompareOp = serde_json::from_str("\"gte\"").unwrap();
222 assert_eq!(op, CompareOp::Gte);
223 }
224
225 #[test]
226 fn json_type_round_trips() {
227 let t: JsonType = serde_json::from_str("\"boolean\"").unwrap();
228 assert_eq!(t, JsonType::Boolean);
229 }
230
231 #[test]
232 fn parses_full_quality_block() {
233 let spec: QualitySpec = serde_json::from_value(serde_json::json!({
234 "record": [
235 { "type": "not_null", "field": "user_id", "on_failure": "quarantine" },
236 { "type": "compare", "field": "age", "op": "gte", "value": 0, "on_failure": "abort" },
237 { "type": "string_length", "field": "name", "min": 1, "max": 256, "on_failure": "quarantine" }
238 ],
239 "batch": [
240 { "type": "row_count", "min": 1, "max": 100000, "on_failure": "abort" },
241 { "type": "unique", "fields": ["id"], "on_failure": "quarantine" }
242 ]
243 }))
244 .unwrap();
245 assert_eq!(spec.record.len(), 3);
246 assert_eq!(spec.batch.len(), 2);
247 assert!(matches!(spec.record[0], RecordCheck::NotNull { .. }));
248 assert!(matches!(spec.batch[1], BatchCheck::Unique { .. }));
249 if let RecordCheck::NotNull {
250 treat_missing_as_null,
251 ..
252 } = &spec.record[0]
253 {
254 assert!(
255 *treat_missing_as_null,
256 "treat_missing_as_null defaults to true"
257 );
258 } else {
259 panic!("expected first record check to be NotNull");
260 }
261 }
262
263 #[test]
264 fn empty_quality_block_defaults_to_no_checks() {
265 let spec: QualitySpec = serde_json::from_str("{}").unwrap();
266 assert!(spec.record.is_empty());
267 assert!(spec.batch.is_empty());
268 }
269}