use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use ailake_catalog::{read_equality_delete_values, EqualityDeleteFile};
use ailake_core::{AilakeError, AilakeResult};
use ailake_store::Store;
use arrow_array::{
Array, BooleanArray, Float32Array, Float64Array, Int32Array, Int64Array, RecordBatch,
StringArray,
};
use arrow_schema::DataType;
pub struct EqualityDeleteFilter {
filters: Vec<(i64, HashMap<String, HashSet<String>>)>,
}
impl EqualityDeleteFilter {
pub async fn from_files(
store: &Arc<dyn Store>,
files: &[EqualityDeleteFile],
) -> AilakeResult<Self> {
let mut filters: Vec<(i64, HashMap<String, HashSet<String>>)> = Vec::new();
for edf in files {
let bytes = store.get(&edf.path).await?;
let pairs = read_equality_delete_values(&bytes)
.map_err(|e| AilakeError::Catalog(e.to_string()))?;
let mut cols: HashMap<String, HashSet<String>> = HashMap::new();
for (col, val) in pairs {
cols.entry(col).or_default().insert(val);
}
filters.push((edf.sequence_number, cols));
}
Ok(Self { filters })
}
pub fn empty() -> Self {
Self {
filters: Vec::new(),
}
}
pub fn is_empty(&self) -> bool {
self.filters.is_empty()
}
fn row_matches(
cols: &HashMap<String, HashSet<String>>,
batch: &RecordBatch,
row_idx: usize,
) -> bool {
let mut any_column_found = false;
for (col_name, delete_values) in cols {
let col_idx = match batch.schema().index_of(col_name.as_str()) {
Ok(i) => i,
Err(_) => continue, };
any_column_found = true;
let array = batch.column(col_idx);
if array.is_null(row_idx) {
return false; }
let val_str: Option<String> = match array.data_type() {
DataType::Utf8 => array
.as_any()
.downcast_ref::<StringArray>()
.map(|a| a.value(row_idx).to_string()),
DataType::LargeUtf8 => array
.as_any()
.downcast_ref::<arrow_array::LargeStringArray>()
.map(|a| a.value(row_idx).to_string()),
DataType::Int32 => array
.as_any()
.downcast_ref::<Int32Array>()
.map(|a| a.value(row_idx).to_string()),
DataType::Int64 => array
.as_any()
.downcast_ref::<Int64Array>()
.map(|a| a.value(row_idx).to_string()),
DataType::Float32 => array
.as_any()
.downcast_ref::<Float32Array>()
.map(|a| a.value(row_idx).to_string()),
DataType::Float64 => array
.as_any()
.downcast_ref::<Float64Array>()
.map(|a| a.value(row_idx).to_string()),
_ => None,
};
match val_str {
Some(s) if delete_values.contains(&s) => {} Some(_) => return false, None => {} }
}
any_column_found }
pub fn should_delete_row(
&self,
batch: &RecordBatch,
row_idx: usize,
data_file_sequence_number: i64,
) -> bool {
for (delete_seq, cols) in &self.filters {
if *delete_seq <= data_file_sequence_number {
continue; }
if Self::row_matches(cols, batch, row_idx) {
return true;
}
}
false
}
pub fn apply(
&self,
batch: RecordBatch,
data_file_sequence_number: i64,
) -> AilakeResult<RecordBatch> {
if self.filters.is_empty() {
return Ok(batch);
}
let n = batch.num_rows();
let keep: Vec<bool> = (0..n)
.map(|i| !self.should_delete_row(&batch, i, data_file_sequence_number))
.collect();
let mask = BooleanArray::from(keep);
arrow_select::filter::filter_record_batch(&batch, &mask)
.map_err(|e| AilakeError::Arrow(e.to_string()))
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use arrow_array::{Int32Array, RecordBatch, StringArray};
use arrow_schema::{DataType, Field, Schema};
use super::EqualityDeleteFilter;
use std::collections::{HashMap, HashSet};
fn make_batch() -> RecordBatch {
let schema = Arc::new(Schema::new(vec![
Field::new("doc_id", DataType::Utf8, true),
Field::new("score", DataType::Int32, true),
]));
RecordBatch::try_new(
schema,
vec![
Arc::new(StringArray::from(vec!["doc-a", "doc-b", "doc-c", "doc-d"])),
Arc::new(Int32Array::from(vec![1, 2, 3, 4])),
],
)
.unwrap()
}
fn filter_with(filters: HashMap<String, HashSet<String>>) -> EqualityDeleteFilter {
EqualityDeleteFilter {
filters: vec![(1, filters)],
}
}
#[test]
fn empty_filter_is_no_op() {
let batch = make_batch();
let f = filter_with(HashMap::new());
let result = f.apply(batch.clone(), 0).unwrap();
assert_eq!(result.num_rows(), 4);
}
#[test]
fn single_value_deleted() {
let mut filters = HashMap::new();
filters.insert("doc_id".into(), ["doc-b".to_string()].into());
let f = filter_with(filters);
let result = f.apply(make_batch(), 0).unwrap();
assert_eq!(result.num_rows(), 3);
let ids = result
.column(0)
.as_any()
.downcast_ref::<StringArray>()
.unwrap();
assert_eq!(ids.value(0), "doc-a");
assert_eq!(ids.value(1), "doc-c");
assert_eq!(ids.value(2), "doc-d");
}
#[test]
fn multiple_values_deleted() {
let mut filters = HashMap::new();
filters.insert(
"doc_id".into(),
["doc-a".to_string(), "doc-c".to_string()].into(),
);
let f = filter_with(filters);
let result = f.apply(make_batch(), 0).unwrap();
assert_eq!(result.num_rows(), 2);
let ids = result
.column(0)
.as_any()
.downcast_ref::<StringArray>()
.unwrap();
assert_eq!(ids.value(0), "doc-b");
assert_eq!(ids.value(1), "doc-d");
}
#[test]
fn column_absent_from_batch_is_skipped() {
let mut filters = HashMap::new();
filters.insert("nonexistent_col".into(), ["x".to_string()].into());
let f = filter_with(filters);
let result = f.apply(make_batch(), 0).unwrap();
assert_eq!(result.num_rows(), 4); }
#[test]
fn numeric_column_deletion() {
let mut filters = HashMap::new();
filters.insert("score".into(), ["2".to_string(), "4".to_string()].into());
let f = filter_with(filters);
let result = f.apply(make_batch(), 0).unwrap();
assert_eq!(result.num_rows(), 2);
let ids = result
.column(0)
.as_any()
.downcast_ref::<StringArray>()
.unwrap();
assert_eq!(ids.value(0), "doc-a");
assert_eq!(ids.value(1), "doc-c");
}
#[test]
fn delete_does_not_mask_a_data_file_with_equal_sequence_number() {
let mut filters = HashMap::new();
filters.insert("doc_id".into(), ["doc-b".to_string()].into());
let f = EqualityDeleteFilter {
filters: vec![(5, filters)],
};
let result = f.apply(make_batch(), 5).unwrap();
assert_eq!(result.num_rows(), 4, "equal sequence number must not mask");
}
#[test]
fn delete_does_not_mask_a_data_file_committed_after_it() {
let mut filters = HashMap::new();
filters.insert("doc_id".into(), ["doc-b".to_string()].into());
let f = EqualityDeleteFilter {
filters: vec![(2, filters)],
};
let result = f.apply(make_batch(), 5).unwrap();
assert_eq!(
result.num_rows(),
4,
"a delete must not mask a data file committed after it"
);
}
#[test]
fn delete_masks_only_data_files_committed_strictly_before_it() {
let mut filters = HashMap::new();
filters.insert("doc_id".into(), ["doc-b".to_string()].into());
let f = EqualityDeleteFilter {
filters: vec![(5, filters)],
};
let result = f.apply(make_batch(), 0).unwrap();
assert_eq!(result.num_rows(), 3, "older data file must still be masked");
}
#[test]
fn multiple_delete_files_each_scoped_to_their_own_sequence_number() {
let mut del_a = HashMap::new();
del_a.insert("doc_id".into(), ["doc-a".to_string()].into());
let mut del_c = HashMap::new();
del_c.insert("doc_id".into(), ["doc-c".to_string()].into());
let f = EqualityDeleteFilter {
filters: vec![(1, del_a), (10, del_c)],
};
let result = f.apply(make_batch(), 5).unwrap();
let ids = result
.column(0)
.as_any()
.downcast_ref::<StringArray>()
.unwrap();
let survivors: Vec<&str> = (0..result.num_rows()).map(|i| ids.value(i)).collect();
assert_eq!(survivors, vec!["doc-a", "doc-b", "doc-d"]);
}
}