1use anyhow::{anyhow, Result};
4use arrow::array::{Array, ArrayRef, StringArray};
5use arrow::record_batch::RecordBatch;
6use std::collections::HashSet;
7
8#[derive(Debug, Clone)]
10pub enum Assertion {
11 NotNull { column: String },
12 Unique { column: String },
14 UniqueKey { columns: Vec<String> },
16 AcceptedValues { column: String, values: Vec<String> },
17}
18
19#[derive(Debug, Clone)]
21pub struct ValidationResult {
22 pub total_rows: usize,
23 pub passed_assertions: usize,
24 pub failed_assertions: usize,
25 pub errors: Vec<String>,
26}
27
28pub struct RecordBatchValidator;
30
31impl RecordBatchValidator {
32 pub fn assert_not_null(batch: &RecordBatch, column: &str) -> Result<()> {
34 let schema = batch.schema();
35 let column_idx = schema
36 .index_of(column)
37 .map_err(|_| anyhow!("Column '{}' not found in RecordBatch schema", column))?;
38
39 let array = batch.column(column_idx);
40 let null_count = array.null_count();
41 if null_count > 0 {
42 return Err(anyhow!(
43 "Assertion failed: Column '{}' has {} null values",
44 column,
45 null_count
46 ));
47 }
48
49 Ok(())
50 }
51
52 pub fn assert_unique(batch: &RecordBatch, column: &str) -> Result<()> {
54 let schema = batch.schema();
55 let column_idx = schema
56 .index_of(column)
57 .map_err(|_| anyhow!("Column '{}' not found in RecordBatch schema", column))?;
58
59 let array = batch.column(column_idx);
60 let string_array = array
61 .as_any()
62 .downcast_ref::<StringArray>()
63 .ok_or_else(|| anyhow!("Column '{}' must be Utf8 type for unique validation", column))?;
64
65 let mut seen = HashSet::new();
66 for i in 0..string_array.len() {
67 if string_array.is_valid(i) {
68 let val = string_array.value(i);
69 if !seen.insert(val) {
70 return Err(anyhow!(
71 "Assertion failed: Duplicate value '{}' found in column '{}'",
72 val,
73 column
74 ));
75 }
76 }
77 }
78
79 Ok(())
80 }
81
82 pub fn assert_unique_key(batches: &[RecordBatch], columns: &[String]) -> Result<()> {
84 if columns.is_empty() {
85 return Err(anyhow!("unique_key assertion requires at least one column"));
86 }
87 if batches.is_empty() {
88 return Ok(());
89 }
90 for col in columns {
91 if batches[0].schema().index_of(col).is_err() {
92 return Err(anyhow!(
93 "Column '{}' not found in RecordBatch schema for unique_key",
94 col
95 ));
96 }
97 }
98
99 let mut seen: HashSet<String> = HashSet::new();
100 for batch in batches {
101 let idxs: Vec<usize> = columns
102 .iter()
103 .map(|c| batch.schema().index_of(c).unwrap())
104 .collect();
105 for row in 0..batch.num_rows() {
106 let mut key = String::new();
107 for (i, &col_idx) in idxs.iter().enumerate() {
108 if i > 0 {
109 key.push('\u{1f}');
110 }
111 key.push_str(&array_value_to_key(batch.column(col_idx), row));
112 }
113 if !seen.insert(key.clone()) {
114 return Err(anyhow!(
115 "Assertion failed: Duplicate composite key {:?} on columns {:?}",
116 key.split('\u{1f}').collect::<Vec<_>>(),
117 columns
118 ));
119 }
120 }
121 }
122 Ok(())
123 }
124
125 pub fn assert_accepted_values(
127 batch: &RecordBatch,
128 column: &str,
129 accepted_values: &[&str],
130 ) -> Result<()> {
131 let schema = batch.schema();
132 let column_idx = schema
133 .index_of(column)
134 .map_err(|_| anyhow!("Column '{}' not found in RecordBatch schema", column))?;
135
136 let array = batch.column(column_idx);
137 let string_array = array.as_any().downcast_ref::<StringArray>().ok_or_else(|| {
138 anyhow!(
139 "Column '{}' must be Utf8 type for accepted_values validation",
140 column
141 )
142 })?;
143
144 let valid_set: HashSet<&str> = accepted_values.iter().copied().collect();
145 for i in 0..string_array.len() {
146 if string_array.is_valid(i) {
147 let val = string_array.value(i);
148 if !valid_set.contains(val) {
149 return Err(anyhow!(
150 "Assertion failed: Value '{}' in column '{}' is not in accepted list {:?}",
151 val,
152 column,
153 accepted_values
154 ));
155 }
156 }
157 }
158
159 Ok(())
160 }
161
162 pub fn validate_batches(batches: &[RecordBatch], assertions: &[Assertion]) -> ValidationResult {
166 let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
167 let mut passed = 0;
168 let mut failed = 0;
169 let mut errors = Vec::new();
170
171 for assertion in assertions {
172 let res = match assertion {
173 Assertion::NotNull { column } => {
174 let mut ok = Ok(());
175 for batch in batches {
176 if let Err(e) = Self::assert_not_null(batch, column) {
177 ok = Err(e);
178 break;
179 }
180 }
181 ok
182 }
183 Assertion::Unique { column } => {
184 Self::assert_unique_key(batches, &[column.clone()])
186 }
187 Assertion::UniqueKey { columns } => Self::assert_unique_key(batches, columns),
188 Assertion::AcceptedValues { column, values } => {
189 let str_refs: Vec<&str> = values.iter().map(|s| s.as_str()).collect();
190 let mut ok = Ok(());
191 for batch in batches {
192 if let Err(e) = Self::assert_accepted_values(batch, column, &str_refs) {
193 ok = Err(e);
194 break;
195 }
196 }
197 ok
198 }
199 };
200
201 match res {
202 Ok(()) => passed += 1,
203 Err(e) => {
204 errors.push(e.to_string());
205 failed += 1;
206 }
207 }
208 }
209
210 ValidationResult {
211 total_rows,
212 passed_assertions: passed,
213 failed_assertions: failed,
214 errors,
215 }
216 }
217}
218
219fn array_value_to_key(array: &ArrayRef, row: usize) -> String {
220 if array.is_null(row) {
221 return "\u{0}".to_string();
222 }
223 if let Some(s) = array.as_any().downcast_ref::<StringArray>() {
225 return s.value(row).to_string();
226 }
227 use arrow::array::AsArray;
229 if let Some(a) = array.as_any().downcast_ref::<arrow::array::Int64Array>() {
230 return a.value(row).to_string();
231 }
232 if let Some(a) = array.as_any().downcast_ref::<arrow::array::Float64Array>() {
233 return a.value(row).to_string();
234 }
235 if let Some(a) = array.as_any().downcast_ref::<arrow::array::Int32Array>() {
236 return a.value(row).to_string();
237 }
238 if let Some(a) = array.as_any().downcast_ref::<arrow::array::UInt64Array>() {
239 return a.value(row).to_string();
240 }
241 if let Some(a) = array.as_any().downcast_ref::<arrow::array::BooleanArray>() {
242 return a.value(row).to_string();
243 }
244 let _ = AsArray::as_string_opt::<i32>(array.as_ref());
246 format!("row{row}")
247}
248
249pub fn assertions_from_model_tests(
251 not_null: Option<&[String]>,
252 unique: Option<&[String]>,
253 accepted_values: Option<&std::collections::HashMap<String, Vec<String>>>,
254) -> Vec<Assertion> {
255 let mut out = Vec::new();
256 if let Some(cols) = not_null {
257 for c in cols {
258 out.push(Assertion::NotNull { column: c.clone() });
259 }
260 }
261 if let Some(cols) = unique {
262 if cols.len() == 1 {
263 out.push(Assertion::UniqueKey {
264 columns: cols.to_vec(),
265 });
266 } else if cols.len() > 1 {
267 out.push(Assertion::UniqueKey {
268 columns: cols.to_vec(),
269 });
270 }
271 }
272 if let Some(map) = accepted_values {
273 for (col, vals) in map {
274 out.push(Assertion::AcceptedValues {
275 column: col.clone(),
276 values: vals.clone(),
277 });
278 }
279 }
280 out
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286 use arrow::array::{Int64Array, StringArray};
287 use arrow::datatypes::{DataType, Field, Schema};
288 use std::sync::Arc;
289
290 #[test]
291 fn test_record_batch_assertions() -> Result<()> {
292 let schema = Arc::new(Schema::new(vec![
293 Field::new("id", DataType::Int64, false),
294 Field::new("status", DataType::Utf8, false),
295 ]));
296
297 let batch = RecordBatch::try_new(
298 schema,
299 vec![
300 Arc::new(Int64Array::from(vec![1, 2, 3])),
301 Arc::new(StringArray::from(vec!["active", "pending", "active"])),
302 ],
303 )?;
304
305 RecordBatchValidator::assert_not_null(&batch, "id")?;
306 RecordBatchValidator::assert_accepted_values(
307 &batch,
308 "status",
309 &["active", "pending", "completed"],
310 )?;
311
312 let assertions = vec![
313 Assertion::NotNull {
314 column: "id".to_string(),
315 },
316 Assertion::AcceptedValues {
317 column: "status".to_string(),
318 values: vec!["active".to_string(), "pending".to_string()],
319 },
320 Assertion::UniqueKey {
321 columns: vec!["id".to_string()],
322 },
323 ];
324
325 let result = RecordBatchValidator::validate_batches(&[batch], &assertions);
326 assert_eq!(result.passed_assertions, 3);
327 assert_eq!(result.failed_assertions, 0);
328
329 Ok(())
330 }
331
332 #[test]
333 fn test_composite_unique_global() -> Result<()> {
334 let schema = Arc::new(Schema::new(vec![
335 Field::new("symbol", DataType::Utf8, false),
336 Field::new("ts", DataType::Int64, false),
337 ]));
338 let b1 = RecordBatch::try_new(
339 schema.clone(),
340 vec![
341 Arc::new(StringArray::from(vec!["A", "B"])),
342 Arc::new(Int64Array::from(vec![1, 1])),
343 ],
344 )?;
345 let b2 = RecordBatch::try_new(
346 schema,
347 vec![
348 Arc::new(StringArray::from(vec!["A"])),
349 Arc::new(Int64Array::from(vec![1])),
350 ],
351 )?;
352 let res = RecordBatchValidator::validate_batches(
353 &[b1, b2],
354 &[Assertion::UniqueKey {
355 columns: vec!["symbol".into(), "ts".into()],
356 }],
357 );
358 assert_eq!(res.failed_assertions, 1);
359 Ok(())
360 }
361}